银川
1.销售报价页面开发、联调
2.销售订单页面开发、联调
3.合同管理页面开发联调
已添加9个文件
已修改19个文件
2154 ■■■■■ 文件已修改
src/api/crm/contract/index.ts 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/report/index.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/saleQuotation/index.ts 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/saleTrace/index.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/crm-customer-select.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/form.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/data.ts 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/detail/index.vue 11 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/index.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/contract/modules/form.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/product/components/data.ts 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/product/components/edit-table.vue 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/report/index.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/data.ts 413 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/form.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/item-form.vue 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 34 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/item-form.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/out/data.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/out/modules/item-form.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/return/data.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/return/modules/item-form.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/contract/index.ts
@@ -38,19 +38,31 @@
    updateTime?: Date;
    products?: ContractProduct[];
    contactName?: string;
    orderId?: number; // å…³è” ERP é”€å”®è®¢å•编号
    orderNo?: string; // ERP é”€å”®è®¢å•单号
  }
  /** åˆåŒäº§å“ä¿¡æ¯ */
  export interface ContractProduct {
    id: number;
    productId: number;
    productName: string;
    productNo: string;
    productUnit: number;
    productPrice: number;
    itemId: number;
    itemName: string;
    itemCode: string;
    itemBarCode: string;
    itemSpecification?: string;
    itemUnitName: string;
    itemUnitName2?: string;
    itemUnitName3?: string;
    itemPrice: number;
    contractPrice: number;
    count: number;
    totalPrice: number;
  }
  /** å®¡æ‰¹æµç¨‹ */
  export interface ApproveProcess {
    key: string;
    name: string;
  }
}
@@ -113,8 +125,17 @@
}
/** æäº¤å®¡æ ¸ */
export function submitContract(id: number) {
  return requestClient.put(`/crm/contract/submit?id=${id}`);
export function submitContract(id: number, processDefinitionKey: string) {
  return requestClient.put('/crm/contract/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–åˆåŒå®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getContractApproveProcessList() {
  return requestClient.get<CrmContractApi.ApproveProcess[]>(
    '/crm/contract/approve-process-list',
  );
}
/** åˆåŒè½¬ç§» */
@@ -131,3 +152,8 @@
export function getRemindContractCount() {
  return requestClient.get<number>('/crm/contract/remind-count');
}
/** æ ¹æ®åˆåŒç”Ÿæˆé”€å”®è®¢å• */
export function generateSaleOrder(id: number) {
  return requestClient.post<number>('/crm/contract/generate-sale-order', null, { params: { id } });
}
src/api/crm/report/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
import { requestClient } from '#/api/request';
export namespace CrmReportApi {
  /** å®¢æˆ·åˆ†æžç»Ÿè®¡ */
  export interface CustomerAnalyze {
    totalCount: number;
    todayCount: number;
    dealCount: number;
    dealRate: number;
    lockCount: number;
    poolCount: number;
  }
  /** å•†æœºåˆ†æžç»Ÿè®¡ */
  export interface BusinessAnalyze {
    totalCount: number;
    todayCount: number;
    totalPrice: number;
    successCount: number;
    successPrice: number;
    successRate: number;
    processingCount: number;
    failCount: number;
  }
  /** åˆåŒåˆ†æžç»Ÿè®¡ */
  export interface ContractAnalyze {
    totalCount: number;
    todayCount: number;
    totalPrice: number;
    approveCount: number;
    approvePrice: number;
    processCount: number;
    receivablePrice: number;
    receivableRate: number;
  }
  /** é”€å”®æ¼æ–—阶段 */
  export interface SalesFunnelStage {
    name: string;
    count: number | null;
    price: number;
    conversionRate: number;
  }
  /** é”€å”®æ¼æ–—统计 */
  export interface SalesFunnel {
    stages: SalesFunnelStage[];
  }
  /** é”€å”®ä¸šç»©é¡¹ */
  export interface SalesPerformanceItem {
    userId: number;
    userName: string;
    deptName: string;
    contractCount: number;
    contractPrice: number;
    receivablePrice: number;
    businessCount: number;
    businessPrice: number;
  }
  /** é”€å”®ä¸šç»©ç»Ÿè®¡ */
  export interface SalesPerformance {
    items: SalesPerformanceItem[];
  }
}
/** èŽ·å–å®¢æˆ·åˆ†æžç»Ÿè®¡ */
export function getCustomerAnalyze(startTime?: string, endTime?: string) {
  return requestClient.get<CrmReportApi.CustomerAnalyze>(
    '/crm/report/get-customer-analyze',
    { params: { startTime, endTime } },
  );
}
/** èŽ·å–å•†æœºåˆ†æžç»Ÿè®¡ */
export function getBusinessAnalyze(startTime?: string, endTime?: string) {
  return requestClient.get<CrmReportApi.BusinessAnalyze>(
    '/crm/report/get-business-analyze',
    { params: { startTime, endTime } },
  );
}
/** èŽ·å–åˆåŒåˆ†æžç»Ÿè®¡ */
export function getContractAnalyze(startTime?: string, endTime?: string) {
  return requestClient.get<CrmReportApi.ContractAnalyze>(
    '/crm/report/get-contract-analyze',
    { params: { startTime, endTime } },
  );
}
/** èŽ·å–é”€å”®æ¼æ–—ç»Ÿè®¡ */
export function getSalesFunnel() {
  return requestClient.get<CrmReportApi.SalesFunnel>(
    '/crm/report/get-sales-funnel',
  );
}
/** èŽ·å–é”€å”®ä¸šç»©ç»Ÿè®¡ */
export function getSalesPerformance(startTime?: string, endTime?: string) {
  return requestClient.get<CrmReportApi.SalesPerformance>(
    '/crm/report/get-sales-performance',
    { params: { startTime, endTime } },
  );
}
src/api/crm/saleQuotation/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
import type { PageParam, PageResult } from '../../../packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace CrmSaleQuotationApi {
  /** æŠ¥ä»·å•信息 */
  export interface SaleQuotation {
    id: number;
    name: string;
    no: string;
    customerId: number;
    customerName?: string;
    businessId?: number;
    businessName?: string;
    contactId?: number;
    contactName?: string;
    quotationTime: Date;
    validUntil?: Date;
    totalProductPrice: number;
    discountPercent: number;
    totalPrice: number;
    taxRate?: number;
    fileUrl?: string;
    remark?: string;
    status: number;
    contractId?: number;
    contractNo?: string;
    ownerUserId: number;
    ownerUserName?: string;
    creator: string;
    creatorName: string;
    createTime?: Date;
    updateTime?: Date;
    items?: SaleQuotationItem[];
  }
  /** æŠ¥ä»·å•物料明细 */
  export interface SaleQuotationItem {
    id: number;
    itemId: number;
    itemName: string;
    itemCode: string;
    itemBarCode?: string;
    itemSpecification?: string;
    itemUnitName: string;
    itemUnitName2?: string;
    itemUnitName3?: string;
    itemPrice: number;
    quotationPrice: number;
    count: number;
    totalPrice: number;
    taxPercent?: number;
    taxPrice?: number;
    remark?: string;
  }
}
/** æŸ¥è¯¢æŠ¥ä»·å•分页列表 */
export function getSaleQuotationPage(params: PageParam) {
  return requestClient.get<PageResult<CrmSaleQuotationApi.SaleQuotation>>(
    '/crm/sale-quotation/page',
    { params },
  );
}
/** æŸ¥è¯¢æŠ¥ä»·å•分页列表(基于客户) */
export function getSaleQuotationPageByCustomer(params: PageParam) {
  return requestClient.get<PageResult<CrmSaleQuotationApi.SaleQuotation>>(
    '/crm/sale-quotation/page-by-customer',
    { params },
  );
}
/** æŸ¥è¯¢æŠ¥ä»·å•分页列表(基于商机) */
export function getSaleQuotationPageByBusiness(params: PageParam) {
  return requestClient.get<PageResult<CrmSaleQuotationApi.SaleQuotation>>(
    '/crm/sale-quotation/page-by-business',
    { params },
  );
}
/** æŸ¥è¯¢æŠ¥ä»·å•详情 */
export function getSaleQuotation(id: number) {
  return requestClient.get<CrmSaleQuotationApi.SaleQuotation>(
    `/crm/sale-quotation/get?id=${id}`,
  );
}
/** æ–°å¢žæŠ¥ä»·å• */
export function createSaleQuotation(data: CrmSaleQuotationApi.SaleQuotation) {
  return requestClient.post('/crm/sale-quotation/create', data);
}
/** ä¿®æ”¹æŠ¥ä»·å• */
export function updateSaleQuotation(data: CrmSaleQuotationApi.SaleQuotation) {
  return requestClient.put('/crm/sale-quotation/update', data);
}
/** åˆ é™¤æŠ¥ä»·å• */
export function deleteSaleQuotation(id: number) {
  return requestClient.delete(`/crm/sale-quotation/delete?id=${id}`);
}
/** è½¬ä¸ºåˆåŒ */
export function convertSaleQuotationToContract(id: number) {
  return requestClient.post<number>('/crm/sale-quotation/convert-contract', null, {
    params: { id },
  });
}
src/api/crm/saleTrace/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
import { requestClient } from '#/api/request';
export namespace CrmSaleTraceApi {
  /** é”€å”®è¿½æº¯ä¿¡æ¯ */
  export interface SaleTrace {
    businessId?: number;
    businessName?: string;
    businessPrice?: number;
    businessCreateTime?: Date;
    customerId?: number;
    customerName?: string;
    contractId?: number;
    contractName?: string;
    contractNo?: string;
    contractPrice?: number;
    contractCreateTime?: Date;
    contractStatus?: number;
    orderId?: number;
    orderNo?: string;
    orderPrice?: number;
    orderCreateTime?: Date;
    receivablePrice?: number;
    receivableItems?: ReceivableItem[];
  }
  /** å›žæ¬¾æ˜Žç»† */
  export interface ReceivableItem {
    receivableId: number;
    receivableNo: string;
    price: number;
    createTime?: Date;
  }
}
/** æ ¹æ®å•†æœºç¼–号获取销售流程追溯信息 */
export function getTraceByBusinessId(businessId: number) {
  return requestClient.get<CrmSaleTraceApi.SaleTrace>(
    '/crm/sale-trace/get-by-business-id',
    { params: { businessId } },
  );
}
/** æ ¹æ®åˆåŒç¼–号获取销售流程追溯信息 */
export function getTraceByContractId(contractId: number) {
  return requestClient.get<CrmSaleTraceApi.SaleTrace>(
    '/crm/sale-trace/get-by-contract-id',
    { params: { contractId } },
  );
}
/** æ ¹æ®å®¢æˆ·ç¼–号获取销售流程追溯列表 */
export function getTraceListByCustomerId(customerId: number) {
  return requestClient.get<CrmSaleTraceApi.SaleTrace[]>(
    '/crm/sale-trace/get-list-by-customer-id',
    { params: { customerId } },
  );
}
src/api/erp/sale/order/index.ts
@@ -8,6 +8,7 @@
    id?: number; // è®¢å•工单编号
    no: string; // é”€å”®è®¢å•号
    customerId: number; // å®¢æˆ·ç¼–号
    customerName?: string; // å®¢æˆ·åç§°
    accountId?: number; // æ”¶æ¬¾è´¦æˆ·ç¼–号
    orderTime: Date; // è®¢å•æ—¶é—´
    totalCount: number; // åˆè®¡æ•°é‡
@@ -22,6 +23,8 @@
    discountPercent?: number; // ä¼˜æƒ çŽ‡ï¼Œç™¾åˆ†æ¯”
    discountPrice?: number; // ä¼˜æƒ é‡‘额,单位:元
    depositPrice?: number; // å®šé‡‘金额,单位:元
    contractId?: number; // å…³è” CRM åˆåŒç¼–号
    contractNo?: string; // CRM åˆåŒå•号
    items?: SaleOrderItem[]; // é”€å”®è®¢å•产品明细列表
  }
src/api/mdm/item/index.ts
@@ -14,6 +14,12 @@
    categoryName?: string;
    unitMeasureId?: number;
    unitMeasureName?: string;
    unitMeasureId2?: number;
    unitMeasureName2?: string;
    unitMeasureRate1?: number;
    unitMeasureId3?: number;
    unitMeasureName3?: string;
    unitMeasureRate2?: number;
    brandId?: number;
    brandName?: string;
    itemType: number;
@@ -26,6 +32,9 @@
    maxStock?: number;
    isBatchManaged: boolean;
    expiryDay?: number;
    warehouseId?: number;
    warehouseCode?: string;
    warehouseName?: string;
    remark?: string;
    createTime?: string;
  }
src/components/crm-customer-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { computed, ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { getCustomerSimpleList } from '#/api/crm/customer';
interface Props {
  value?: number;
  disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  value: undefined,
  disabled: false,
});
const emit = defineEmits(['update:value', 'change']);
const customerList = ref<CrmCustomerApi.Customer[]>([]);
const loading = ref(false);
const options = computed(() => {
  return customerList.value.map((item) => ({
    label: item.name,
    value: item.id,
  }));
});
const selectedValue = ref(props.value);
watch(
  () => props.value,
  (val) => {
    selectedValue.value = val;
  },
);
watch(selectedValue, (val) => {
  emit('update:value', val);
  const customer = customerList.value.find((item) => item.id === val);
  emit('change', customer);
});
async function loadCustomers() {
  if (customerList.value.length > 0) return;
  loading.value = true;
  try {
    customerList.value = await getCustomerSimpleList();
  } finally {
    loading.value = false;
  }
}
function filterOption(input: string, option: any) {
  return option.label.toLowerCase().includes(input.toLowerCase());
}
loadCustomers();
</script>
<template>
  <Select
    v-model:value="selectedValue"
    :options="options"
    :loading="loading"
    :disabled="disabled"
    placeholder="请选择客户"
    show-search
    :filter-option="filterOption"
    allow-clear
  />
</template>
src/views/basicData/mdm/data.ts
@@ -6,6 +6,7 @@
import { z } from '#/adapter/form';
import { getUnitPage } from '#/api/mdm/unit';
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
/** ç‰©æ–™ç±»åž‹é€‰é¡¹ */
export const ITEM_TYPE_OPTIONS = [
@@ -72,10 +73,19 @@
      rules: 'required',
    },
    {
      component: 'Input',
      fieldName: 'specification',
      label: '规格型号',
      componentProps: {
        placeholder: '请输入规格型号',
      },
    },
    {
      component: 'ApiSelect',
      fieldName: 'unitMeasureId',
      label: '计量单位',
      rules: 'required',
      formItemClass: 'col-span-1',
      componentProps: {
        placeholder: '请选择计量单位',
        allowClear: true,
@@ -83,6 +93,74 @@
          const res = await getUnitPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
      component: 'ApiSelect',
      fieldName: 'unitMeasureId2',
      label: '辅单位1',
      formItemClass: 'col-span-1',
      componentProps: {
        placeholder: '请选择辅单位1',
        allowClear: true,
        api: async () => {
          const res = await getUnitPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
      component: 'InputNumber',
      fieldName: 'unitMeasureRate1',
      label: '辅单位1换算比率',
      formItemClass: 'col-span-1',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 6,
        placeholder: '请输入换算比率',
      },
    },
    {
      component: 'ApiSelect',
      fieldName: 'unitMeasureId3',
      label: '辅单位2',
      formItemClass: 'col-span-1',
      componentProps: {
        placeholder: '请选择辅单位2',
        allowClear: true,
        api: async () => {
          const res = await getUnitPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
      component: 'InputNumber',
      fieldName: 'unitMeasureRate2',
      label: '辅单位2换算比率',
      formItemClass: 'col-span-1',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 6,
        placeholder: '请输入换算比率',
      },
    },
    {
      component: 'ApiSelect',
      fieldName: 'warehouseId',
      label: '默认仓库',
      componentProps: {
        placeholder: '请选择默认仓库',
        allowClear: true,
        api: getWarehouseSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
@@ -275,6 +353,31 @@
      minWidth: 80,
    },
    {
      field: 'unitMeasureName2',
      title: '辅单位1',
      minWidth: 80,
    },
    {
      field: 'unitMeasureRate1',
      title: '辅单位1换算比率',
      minWidth: 130,
    },
    {
      field: 'unitMeasureName3',
      title: '辅单位2',
      minWidth: 80,
    },
    {
      field: 'unitMeasureRate2',
      title: '辅单位2换算比率',
      minWidth: 130,
    },
    {
      field: 'warehouseName',
      title: '默认仓库',
      minWidth: 100,
    },
    {
      field: 'itemType',
      title: '物料类型',
      minWidth: 100,
src/views/basicData/mdm/modules/form.vue
@@ -28,7 +28,7 @@
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 100,
    labelWidth: 110,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
@@ -74,7 +74,7 @@
</script>
<template>
  <Modal class="w-[800px]" :title="getTitle">
  <Modal class="w-[1000px]" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/basicData/mdm/unit/data.ts
@@ -5,7 +5,6 @@
import { getDictOptions } from '#/packages/effects/hooks/src';
import { z } from '#/adapter/form';
import { getUnitPage } from '#/api/mdm/unit';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -34,61 +33,6 @@
      rules: 'required',
      componentProps: {
        placeholder: '请输入单位名称',
      },
    },
    {
      component: 'Switch',
      fieldName: 'primaryFlag',
      label: '是否主单位',
      componentProps: {
        class: 'w-auto',
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      component: 'ApiSelect',
      fieldName: 'primaryId',
      label: '主单位',
      dependencies: {
        triggerFields: ['primaryFlag'],
        show: (values) => {
          return values.primaryFlag === false;
        },
        required: (values) => {
          return values.primaryFlag === false;
        },
      },
      componentProps: {
        placeholder: '请选择主单位',
        allowClear: true,
        api: async () => {
          const res = await getUnitPage({ pageNo: 1, pageSize: 100, primaryFlag: true, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
      component: 'InputNumber',
      fieldName: 'changeRate',
      label: '换算比例',
      dependencies: {
        triggerFields: ['primaryFlag'],
        show: (values) => {
          return values.primaryFlag === false;
        },
        required: (values) => {
          return values.primaryFlag === false;
        },
      },
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 4,
        placeholder: '请输入换算比例',
      },
    },
    {
@@ -161,17 +105,6 @@
      field: 'name',
      title: '单位名称',
      minWidth: 150,
    },
    {
      field: 'primaryFlag',
      title: '主单位',
      minWidth: 80,
      slots: { default: 'primaryFlag' },
    },
    {
      field: 'changeRate',
      title: '换算比例',
      minWidth: 100,
    },
    {
      field: 'status',
src/views/crm/contract/data.ts
@@ -1,7 +1,6 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '../../../packages/constants/src';
import { useUserStore } from '../../../packages/stores/src';
import { erpPriceInputFormatter, erpPriceMultiply } from '../../../packages/utils/src';
@@ -402,16 +401,20 @@
      field: 'auditStatus',
      fixed: 'right',
      minWidth: 120,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
      slots: { default: 'auditStatus' },
    },
    {
      title: '关联订单',
      field: 'orderNo',
      fixed: 'right',
      minWidth: 150,
      slots: { default: 'orderNo' },
    },
    {
      title: '操作',
      field: 'actions',
      fixed: 'right',
      minWidth: 130,
      minWidth: 220,
      slots: { default: 'actions' },
    },
  ];
src/views/crm/contract/detail/index.vue
@@ -38,6 +38,7 @@
const contract = ref<CrmContractApi.Contract>({} as CrmContractApi.Contract); // åˆåŒè¯¦æƒ…
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // æ“ä½œæ—¥å¿—
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // å›¢é˜Ÿæˆå‘˜åˆ—表 Ref
const activeTab = ref('1'); // å½“前激活的 Tab
const [Descriptions] = useDescription({
  bordered: false,
@@ -128,13 +129,13 @@
      <Descriptions :data="contract" />
    </Card>
    <Card class="mt-4 min-h-[60%]">
      <Tabs>
        <Tabs.TabPane tab="跟进记录" key="1" :force-render="true">
          <FollowUp :biz-id="contractId" :biz-type="BizTypeEnum.CRM_CONTRACT" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="基本信息" key="2" :force-render="true">
      <Tabs v-model:activeKey="activeTab">
        <Tabs.TabPane tab="基本信息" key="1" :force-render="true">
          <ContractDetailsInfo :contract="contract" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="跟进记录" key="2" :force-render="true">
          <FollowUp :biz-id="contractId" :biz-type="BizTypeEnum.CRM_CONTRACT" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="产品" key="3" :force-render="true">
          <ProductDetailsList
            :biz-id="contractId"
src/views/crm/contract/index.vue
@@ -8,12 +8,14 @@
import { Page, useVbenModal } from '../../../packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '../../../packages/utils/src';
import { Button, message, Tabs } from 'ant-design-vue';
import { Button, message, Modal, Select, Tabs, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteContract,
  exportContract,
  generateSaleOrder,
  getContractApproveProcessList,
  getContractPage,
  submitContract,
} from '#/api/crm/contract';
@@ -76,18 +78,34 @@
  }
}
/** æäº¤å®¡æ ¸ */
/** æäº¤å®¡æ‰¹å¼¹çª— */
const submitModalVisible = ref(false);
const currentRow = ref<CrmContractApi.Contract>();
const processList = ref<CrmContractApi.ApproveProcess[]>([]);
const selectedProcessKey = ref<string>();
const submitLoading = ref(false);
async function handleSubmit(row: CrmContractApi.Contract) {
  const hideLoading = message.loading({
    content: '提交审核中...',
    duration: 0,
  });
  const res = await getContractApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  submitModalVisible.value = true;
}
async function confirmSubmit() {
  if (!selectedProcessKey.value) {
    message.warning('请选择审批流程');
    return;
  }
  submitLoading.value = true;
  try {
    await submitContract(row.id!);
    message.success('提交审核成功');
    await submitContract(currentRow.value!.id!, selectedProcessKey.value);
    message.success('提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
    hideLoading();
    submitLoading.value = false;
  }
}
@@ -117,6 +135,39 @@
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
/** ç”Ÿæˆé”€å”®è®¢å• */
async function handleGenerateOrder(row: CrmContractApi.Contract) {
  if (row.auditStatus !== 20) {
    message.warning('只有已审批的合同才能生成销售订单');
    return;
  }
  if (row.orderId) {
    message.warning('该合同已生成销售订单');
    return;
  }
  const hideLoading = message.loading({
    content: '正在生成销售订单...',
    duration: 0,
  });
  try {
    const orderId = await generateSaleOrder(row.id!);
    message.success('生成销售订单成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹é”€å”®è®¢å• */
function handleViewOrder(row: CrmContractApi.Contract) {
  if (row.orderId) {
    push({
      name: 'ErpSaleOrder',
      query: { id: row.orderId },
    });
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -152,7 +203,8 @@
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-actions>
        <Tabs class="w-full" @change="handleChangeSceneType">
@@ -201,6 +253,18 @@
          {{ row.signContactName }}
        </Button>
      </template>
      <template #auditStatus="{ row }">
        <Tag v-if="row.auditStatus === 0" color="default">未提交</Tag>
        <Tag v-if="row.auditStatus === 10" color="warning">审批中</Tag>
        <Tag v-if="row.auditStatus === 20" color="success">审核通过</Tag>
        <Tag v-if="row.auditStatus === 30" color="error">审核不通过</Tag>
      </template>
      <template #orderNo="{ row }">
        <Button v-if="row.orderId" type="link" @click="handleViewOrder(row)">
          {{ row.orderNo }}
        </Button>
        <span v-else>-</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
@@ -213,7 +277,7 @@
              ifShow: row.auditStatus === 0,
            },
            {
              label: '提交审核',
              label: '提交审批',
              type: 'link',
              auth: ['crm:contract:update'],
              onClick: handleSubmit.bind(null, row),
@@ -227,6 +291,20 @@
              ifShow: row.auditStatus !== 0,
            },
            {
              label: '生成销售订单',
              type: 'link',
              auth: ['crm:contract:update'],
              onClick: handleGenerateOrder.bind(null, row),
              ifShow: row.auditStatus === 20 && !row.orderId,
            },
            {
              label: '查看订单',
              type: 'link',
              auth: ['erp:sale-order:query'],
              onClick: handleViewOrder.bind(null, row),
              ifShow: !!row.orderId,
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
@@ -235,10 +313,34 @@
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
              ifShow: row.auditStatus === 0,
            },
          ]"
        />
      </template>
    </Grid>
    <!-- æäº¤å®¡æ‰¹å¼¹çª— -->
    <Modal
      v-model:open="submitModalVisible"
      title="选择审批流程"
      width="400px"
      @ok="confirmSubmit"
      :confirmLoading="submitLoading"
    >
      <Select
        v-model:value="selectedProcessKey"
        placeholder="请选择审批流程"
        class="w-full"
      >
        <Select.Option
          v-for="item in processList"
          :key="item.key"
          :value="item.key"
        >
          {{ item.name }}
        </Select.Option>
      </Select>
    </Modal>
  </Page>
</template>
src/views/crm/contract/modules/form.vue
@@ -105,7 +105,7 @@
</script>
<template>
  <Modal :title="getTitle" class="w-1/2">
  <Modal :title="getTitle" class="w-[80%]">
    <Form class="mx-4">
      <template #product="slotProps">
        <ProductEditTable
src/views/crm/product/components/data.ts
@@ -1,31 +1,29 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '../../../../packages/constants/src';
/** äº§å“è¯¦æƒ…列表的列定义 */
export function useDetailListColumns(
  showBusinessPrice: boolean,
): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'productName',
      field: 'itemName',
      title: '产品名称',
    },
    {
      field: 'productNo',
      title: '产品条码',
      field: 'itemCode',
      title: '物料编码',
    },
    {
      field: 'productUnit',
      title: '产品单位',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_PRODUCT_UNIT },
      },
      field: 'itemBarCode',
      title: '物料条码',
    },
    {
      field: 'productPrice',
      title: '产品价格(元)',
      field: 'itemUnitName',
      title: '单位',
    },
    {
      field: 'itemPrice',
      title: '原价(元)',
      formatter: 'formatAmount2',
    },
    {
@@ -58,36 +56,47 @@
  return [
    { type: 'seq', title: '序号', minWidth: 50 },
    {
      field: 'productId',
      field: 'itemId',
      title: '产品名称',
      minWidth: 100,
      slots: { default: 'productId' },
      slots: { default: 'itemId' },
    },
    {
      field: 'productNo',
      field: 'itemSpecification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'itemBarCode',
      title: '条码',
      minWidth: 150,
    },
    {
      field: 'productUnit',
      field: 'itemUnitName',
      title: '单位',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_PRODUCT_UNIT },
      },
      minWidth: 80,
    },
    {
      field: 'productPrice',
      title: '价格(元)',
      field: 'itemUnitName2',
      title: '辅单位1',
      minWidth: 80,
    },
    {
      field: 'itemUnitName3',
      title: '辅单位2',
      minWidth: 80,
    },
    {
      field: 'itemPrice',
      title: '原价(元)',
      minWidth: 100,
      formatter: 'formatAmount2',
    },
    {
      field: 'sellingPrice',
      title: '售价(元)',
      field: 'contractPrice',
      title: '合同价(元)',
      minWidth: 100,
      slots: { default: 'sellingPrice' },
      slots: { default: 'contractPrice' },
    },
    {
      field: 'count',
src/views/crm/product/components/edit-table.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import type { CrmContractApi } from '#/api/crm/contract';
import type { CrmProductApi } from '#/api/crm/product';
import type { MdmItemApi } from '#/api/mdm/item';
import { nextTick, onMounted, ref, watch } from 'vue';
@@ -11,7 +11,7 @@
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { BizTypeEnum } from '#/api/crm/permission';
import { getProductSimpleList } from '#/api/crm/product';
import { getItemPage } from '#/api/mdm/item';
import { $t } from '#/locales';
import { useProductEditTableColumns } from './data';
@@ -39,34 +39,36 @@
}
/** åˆ‡æ¢äº§å“æ—¶åŒæ­¥åŸºç¡€ä¿¡æ¯ */
function handleProductChange(productId: any, row: any) {
  const product = productOptions.value.find((p) => p.id === productId);
function handleProductChange(itemId: any, row: any) {
  const product = productOptions.value.find((p) => p.id === itemId);
  if (!product) {
    return;
  }
  row.productUnit = product.unit;
  row.productNo = product.no;
  row.productPrice = product.price;
  row.sellingPrice = product.price;
  row.count = 0;
  row.totalPrice = 0;
  row.itemId = itemId;
  row.itemName = product.name;
  row.itemCode = product.code;
  row.itemBarCode = product.barCode;
  row.itemUnitName = product.unitMeasureName;
  row.itemUnitName2 = product.unitMeasureName2;
  row.itemUnitName3 = product.unitMeasureName3;
  row.itemSpecification = product.specification;
  row.itemPrice = product.salesPrice;
  row.contractPrice = product.salesPrice;
  row.count = row.count || 1;
  row.totalPrice = erpPriceMultiply(row.contractPrice, row.count) ?? 0;
  handleUpdateValue(row);
}
/** é‡‘额变动时重新计算合计 */
function handlePriceChange(row: any) {
  row.totalPrice = erpPriceMultiply(row.sellingPrice, row.count) ?? 0;
  row.totalPrice = erpPriceMultiply(row.contractPrice, row.count) ?? 0;
  handleUpdateValue(row);
}
/** å°†æœ€æ–°æ•°æ®å†™å›žå¹¶é€šçŸ¥çˆ¶ç»„ä»¶ */
function handleUpdateValue(row: any) {
  const index = tableData.value.findIndex((item) => item.id === row.id);
  if (props.bizType === BizTypeEnum.CRM_BUSINESS) {
    row.businessPrice = row.sellingPrice;
  } else if (props.bizType === BizTypeEnum.CRM_CONTRACT) {
    row.contractPrice = row.sellingPrice;
  }
  row.totalPrice = erpPriceMultiply(row.contractPrice, row.count) ?? 0;
  if (index === -1) {
    row.id = tableData.value.length + 1;
    tableData.value.push(row);
@@ -110,15 +112,6 @@
    }
    await nextTick();
    tableData.value = products;
    if (props.bizType === BizTypeEnum.CRM_BUSINESS) {
      tableData.value.forEach((item) => {
        item.sellingPrice = item.businessPrice;
      });
    } else if (props.bizType === BizTypeEnum.CRM_CONTRACT) {
      tableData.value.forEach((item) => {
        item.sellingPrice = item.contractPrice;
      });
    }
    await gridApi.grid.reloadData(tableData.value);
  },
  {
@@ -127,26 +120,27 @@
);
/** åˆå§‹åŒ– */
const productOptions = ref<CrmProductApi.Product[]>([]); // äº§å“ä¸‹æ‹‰é€‰é¡¹
const productOptions = ref<MdmItemApi.Item[]>([]); // ç‰©æ–™ä¸‹æ‹‰é€‰é¡¹
onMounted(async () => {
  productOptions.value = await getProductSimpleList();
  const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
  productOptions.value = res.list || [];
});
</script>
<template>
  <Grid class="w-full">
    <template #productId="{ row }">
    <template #itemId="{ row }">
      <Select
        v-model:value="row.productId"
        v-model:value="row.itemId"
        :options="productOptions"
        :field-names="{ label: 'name', value: 'id' }"
        class="w-full"
        @change="handleProductChange($event, row)"
      />
    </template>
    <template #sellingPrice="{ row }">
    <template #contractPrice="{ row }">
      <InputNumber
        v-model:value="row.sellingPrice"
        v-model:value="row.contractPrice"
        :min="0.001"
        :precision="2"
        @change="handlePriceChange(row)"
@@ -180,7 +174,7 @@
            type: 'link',
            danger: true,
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', [row.name]),
              title: $t('ui.actionMessage.deleteConfirm', [row.itemName]),
              confirm: handleDelete.bind(null, row),
            },
          },
src/views/crm/report/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script lang="ts" setup>
import type { CrmReportApi } from '#/api/crm/report';
import { computed, onMounted, ref } from 'vue';
import { Page } from '../../../packages/effects/common-ui/src';
import { Card, Col, DatePicker, Row, Statistic, Table } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  getBusinessAnalyze,
  getContractAnalyze,
  getCustomerAnalyze,
  getSalesFunnel,
  getSalesPerformance,
} from '#/api/crm/report';
defineOptions({ name: 'CrmReportDashboard' });
const loading = ref(false);
const dateRange = ref<[string, string] | null>(null);
const customerAnalyze = ref<CrmReportApi.CustomerAnalyze>();
const businessAnalyze = ref<CrmReportApi.BusinessAnalyze>();
const contractAnalyze = ref<CrmReportApi.ContractAnalyze>();
const salesFunnel = ref<CrmReportApi.SalesFunnel>();
const salesPerformance = ref<CrmReportApi.SalesPerformance>();
const startTime = computed(() =>
  dateRange.value?.[0] ? dayjs(dateRange.value[0]).format('YYYY-MM-DD') : undefined,
);
const endTime = computed(() =>
  dateRange.value?.[1] ? dayjs(dateRange.value[1]).format('YYYY-MM-DD') : undefined,
);
const performanceColumns = [
  { title: '姓名', dataIndex: 'userName', width: 120 },
  { title: '部门', dataIndex: 'deptName', width: 150 },
  { title: '合同数量', dataIndex: 'contractCount', width: 100 },
  { title: '合同金额', dataIndex: 'contractPrice', width: 120, formatter: 'formatAmount2' },
  { title: '回款金额', dataIndex: 'receivablePrice', width: 120, formatter: 'formatAmount2' },
  { title: '商机数量', dataIndex: 'businessCount', width: 100 },
  { title: '商机金额', dataIndex: 'businessPrice', width: 120, formatter: 'formatAmount2' },
];
async function loadData() {
  loading.value = true;
  try {
    const [customer, business, contract, funnel, performance] = await Promise.all([
      getCustomerAnalyze(startTime.value, endTime.value),
      getBusinessAnalyze(startTime.value, endTime.value),
      getContractAnalyze(startTime.value, endTime.value),
      getSalesFunnel(),
      getSalesPerformance(startTime.value, endTime.value),
    ]);
    customerAnalyze.value = customer;
    businessAnalyze.value = business;
    contractAnalyze.value = contract;
    salesFunnel.value = funnel;
    salesPerformance.value = performance;
  } finally {
    loading.value = false;
  }
}
function handleDateChange() {
  loadData();
}
onMounted(() => {
  loadData();
});
</script>
<template>
  <Page auto-content-height>
    <div class="p-4">
      <Card title="CRM æ•°æ®æ¦‚览" class="mb-4">
        <div class="mb-4">
          <DatePicker.RangePicker
            v-model:value="dateRange"
            :placeholder="['开始日期', '结束日期']"
            format="YYYY-MM-DD"
            @change="handleDateChange"
          />
        </div>
      </Card>
      <Row :gutter="16" class="mb-4">
        <Col :span="6">
          <Card title="客户分析" :loading="loading">
            <Statistic title="总客户数" :value="customerAnalyze?.totalCount || 0" />
            <Statistic title="今日新增" :value="customerAnalyze?.todayCount || 0" class="mt-2" />
            <Statistic title="成交客户" :value="customerAnalyze?.dealCount || 0" class="mt-2" />
            <Statistic
              title="成交率"
              :value="customerAnalyze?.dealRate || 0"
              :precision="2"
              suffix="%"
              class="mt-2"
            />
          </Card>
        </Col>
        <Col :span="6">
          <Card title="商机分析" :loading="loading">
            <Statistic title="总商机数" :value="businessAnalyze?.totalCount || 0" />
            <Statistic title="商机金额" :value="businessAnalyze?.totalPrice || 0" :precision="2" class="mt-2" />
            <Statistic title="成功商机" :value="businessAnalyze?.successCount || 0" class="mt-2" />
            <Statistic
              title="成功率"
              :value="businessAnalyze?.successRate || 0"
              :precision="2"
              suffix="%"
              class="mt-2"
            />
          </Card>
        </Col>
        <Col :span="6">
          <Card title="合同分析" :loading="loading">
            <Statistic title="总合同数" :value="contractAnalyze?.totalCount || 0" />
            <Statistic title="合同金额" :value="contractAnalyze?.totalPrice || 0" :precision="2" class="mt-2" />
            <Statistic title="审批中" :value="contractAnalyze?.processCount || 0" class="mt-2" />
            <Statistic
              title="回款率"
              :value="contractAnalyze?.receivableRate || 0"
              :precision="2"
              suffix="%"
              class="mt-2"
            />
          </Card>
        </Col>
        <Col :span="6">
          <Card title="待处理" :loading="loading">
            <Statistic title="客户公海" :value="customerAnalyze?.poolCount || 0" />
            <Statistic title="锁定客户" :value="customerAnalyze?.lockCount || 0" class="mt-2" />
            <Statistic title="进行中商机" :value="businessAnalyze?.processingCount || 0" class="mt-2" />
            <Statistic title="失败商机" :value="businessAnalyze?.failCount || 0" class="mt-2" />
          </Card>
        </Col>
      </Row>
      <Row :gutter="16">
        <Col :span="12">
          <Card title="销售漏斗" :loading="loading">
            <Table
              :dataSource="salesFunnel?.stages || []"
              :columns="[
                { title: '阶段', dataIndex: 'name', width: 150 },
                { title: '数量', dataIndex: 'count', width: 100 },
                { title: '金额', dataIndex: 'price', width: 120 },
                { title: '转化率', dataIndex: 'conversionRate', width: 100, customRender: ({ text }) => `${text}%` },
              ]"
              :pagination="false"
              size="small"
              rowKey="name"
            />
          </Card>
        </Col>
        <Col :span="12">
          <Card title="销售业绩" :loading="loading">
            <Table
              :dataSource="salesPerformance?.items || []"
              :columns="performanceColumns"
              :pagination="false"
              size="small"
              rowKey="userId"
            />
          </Card>
        </Col>
      </Row>
    </div>
  </Page>
</template>
src/views/crm/saleQuotation/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,413 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { erpPriceMultiply } from '#/packages/utils/src';
import { z } from '#/adapter/form';
import { getSimpleBusinessList } from '#/api/crm/business';
import { getSimpleContactList } from '#/api/crm/contact';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getItemPage } from '#/api/mdm/item';
import { getSimpleUserList } from '#/api/system/user';
import { useUserStore } from '#/packages/stores/src';
/** æŠ¥ä»·å•状态 */
export const QUOTATION_STATUS = {
  DRAFT: 0, // è‰ç¨¿
  CONVERTED: 10, // å·²è½¬åˆåŒ
};
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  const userStore = useUserStore();
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '报价单号',
      component: 'Input',
      componentProps: {
        placeholder: '保存时自动生成',
        disabled: true,
      },
    },
    {
      fieldName: 'name',
      label: '报价单名称',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '请输入报价单名称',
      },
    },
    {
      fieldName: 'ownerUserId',
      label: '负责人',
      component: 'ApiSelect',
      componentProps: {
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
      },
      defaultValue: userStore.userInfo?.id,
      rules: 'required',
    },
    {
      fieldName: 'customerId',
      label: '客户名称',
      component: 'ApiSelect',
      rules: 'required',
      componentProps: {
        api: getCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择客户',
      },
    },
    {
      fieldName: 'businessId',
      label: '商机名称',
      component: 'Select',
      componentProps: {
        options: [],
        placeholder: '请选择商机',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['customerId'],
        disabled: (values) => !values.customerId,
        async componentProps(values) {
          if (!values.customerId) {
            return { options: [], placeholder: '请选择客户' };
          }
          const res = await getSimpleBusinessList();
          const list = res.filter((item) => item.customerId === values.customerId);
          return {
            options: list.map((item) => ({ label: item.name, value: item.id })),
            placeholder: '请选择商机',
          };
        },
      },
    },
    {
      fieldName: 'contactId',
      label: '联系人',
      component: 'Select',
      componentProps: {
        options: [],
        placeholder: '请选择联系人',
        allowClear: true,
      },
      dependencies: {
        triggerFields: ['customerId'],
        disabled: (values) => !values.customerId,
        async componentProps(values) {
          if (!values.customerId) {
            return { options: [], placeholder: '请选择客户' };
          }
          const res = await getSimpleContactList();
          const list = res.filter((item) => item.customerId === values.customerId);
          return {
            options: list.map((item) => ({ label: item.name, value: item.id })),
            placeholder: '请选择联系人',
          };
        },
      },
    },
    {
      fieldName: 'quotationTime',
      label: '报价日期',
      component: 'DatePicker',
      componentProps: {
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'x',
        placeholder: '请选择报价日期',
      },
    },
    {
      fieldName: 'validUntil',
      label: '有效期至',
      component: 'DatePicker',
      componentProps: {
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'x',
        placeholder: '请选择有效期',
      },
    },
    {
      fieldName: 'taxRate',
      label: '税率(%)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        max: 100,
        precision: 2,
        placeholder: '请输入税率',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
    {
      fieldName: 'items',
      label: '物料清单',
      component: 'Input',
      formItemClass: 'col-span-3',
    },
    {
      fieldName: 'totalProductPrice',
      label: '物料总金额',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        disabled: true,
      },
      rules: z.number().min(0).optional().default(0),
    },
    {
      fieldName: 'discountPercent',
      label: '整单折扣(%)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入折扣',
      },
      rules: z.number().min(0).max(100).optional().default(0),
    },
    {
      fieldName: 'totalPrice',
      label: '折扣后金额',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        disabled: true,
      },
      dependencies: {
        triggerFields: ['totalProductPrice', 'discountPercent'],
        trigger(values, form) {
          const discountPrice =
            erpPriceMultiply(values.totalProductPrice, values.discountPercent / 100) ?? 0;
          form.setFieldValue('totalPrice', values.totalProductPrice - discountPrice);
        },
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '报价单号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入报价单号',
        allowClear: true,
      },
    },
    {
      fieldName: 'name',
      label: '报价单名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入报价单名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'customerId',
      label: '客户',
      component: 'ApiSelect',
      componentProps: {
        api: getCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择客户',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        placeholder: '请选择状态',
        allowClear: true,
        options: [
          { label: '草稿', value: 0 },
          { label: '已转合同', value: 10 },
        ],
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '报价单号',
      minWidth: 180,
      fixed: 'left',
    },
    {
      field: 'name',
      title: '报价单名称',
      minWidth: 160,
      fixed: 'left',
    },
    {
      field: 'customerName',
      title: '客户名称',
      minWidth: 120,
    },
    {
      field: 'businessName',
      title: '商机名称',
      minWidth: 130,
    },
    {
      field: 'totalPrice',
      title: '报价金额(元)',
      minWidth: 140,
      formatter: 'formatAmount2',
    },
    {
      field: 'quotationTime',
      title: '报价日期',
      minWidth: 120,
      formatter: 'formatDateTime',
    },
    {
      field: 'validUntil',
      title: '有效期至',
      minWidth: 120,
      formatter: 'formatDateTime',
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      slots: { default: 'status' },
    },
    {
      field: 'contractNo',
      title: '关联合同',
      minWidth: 150,
    },
    {
      field: 'ownerUserName',
      title: '负责人',
      minWidth: 120,
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 260,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** ç‰©æ–™æ˜Žç»†è¡¨æ ¼åˆ— */
export function useItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', minWidth: 50, fixed: 'left' },
    {
      field: 'itemId',
      title: '物料名称',
      minWidth: 200,
      slots: { default: 'itemId' },
    },
    {
      field: 'itemSpecification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'itemBarCode',
      title: '条码',
      minWidth: 120,
    },
    {
      field: 'itemUnitName',
      title: '单位',
      minWidth: 80,
    },
    {
      field: 'itemUnitName2',
      title: '辅单位1',
      minWidth: 80,
    },
    {
      field: 'itemUnitName3',
      title: '辅单位2',
      minWidth: 80,
    },
    {
      field: 'itemPrice',
      title: '原价(元)',
      minWidth: 100,
      formatter: 'formatAmount2',
    },
    {
      field: 'quotationPrice',
      title: '报价(元)',
      minWidth: 100,
      slots: { default: 'quotationPrice' },
    },
    {
      field: 'count',
      title: '数量',
      minWidth: 100,
      slots: { default: 'count' },
    },
    {
      field: 'totalPrice',
      title: '合计',
      minWidth: 100,
      formatter: 'formatAmount2',
    },
    {
      title: '操作',
      width: 50,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/crm/saleQuotation/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,210 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmSaleQuotationApi } from '#/api/crm/saleQuotation';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { Button, message, Modal, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  convertSaleQuotationToContract,
  deleteSaleQuotation,
  getSaleQuotationPage,
} from '#/api/crm/saleQuotation';
import { $t } from '#/locales';
import { QUOTATION_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºæŠ¥ä»·å• */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑报价单 */
function handleEdit(row: CrmSaleQuotationApi.SaleQuotation) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤æŠ¥ä»·å• */
async function handleDelete(row: CrmSaleQuotationApi.SaleQuotation) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name]),
    duration: 0,
  });
  try {
    await deleteSaleQuotation(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹æŠ¥ä»·å•详情 */
function handleDetail(row: CrmSaleQuotationApi.SaleQuotation) {
  push({ name: 'CrmSaleQuotationDetail', params: { id: row.id } });
}
/** æŸ¥çœ‹å®¢æˆ·è¯¦æƒ… */
function handleCustomerDetail(row: CrmSaleQuotationApi.SaleQuotation) {
  push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** æŸ¥çœ‹å•†æœºè¯¦æƒ… */
function handleBusinessDetail(row: CrmSaleQuotationApi.SaleQuotation) {
  if (row.businessId) {
    push({ name: 'CrmBusinessDetail', params: { id: row.businessId } });
  }
}
/** æŸ¥çœ‹åˆåŒè¯¦æƒ… */
function handleContractDetail(row: CrmSaleQuotationApi.SaleQuotation) {
  if (row.contractId) {
    push({ name: 'CrmContractDetail', params: { id: row.contractId } });
  }
}
/** è½¬ä¸ºåˆåŒ */
async function handleConvertContract(row: CrmSaleQuotationApi.SaleQuotation) {
  Modal.confirm({
    title: '确认转合同',
    content: '确认将该报价单转为合同?',
    async onOk() {
      const contractId = await convertSaleQuotationToContract(row.id!);
      message.success('转合同成功');
      push({ name: 'CrmContractDetail', params: { id: contractId } });
    },
  });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getSaleQuotationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<CrmSaleQuotationApi.SaleQuotation>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报价单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sale-quotation:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
          {{ row.name }}
        </Button>
      </template>
      <template #customerName="{ row }">
        <Button type="link" @click="handleCustomerDetail(row)">
          {{ row.customerName }}
        </Button>
      </template>
      <template #businessName="{ row }">
        <Button v-if="row.businessId" type="link" @click="handleBusinessDetail(row)">
          {{ row.businessName }}
        </Button>
        <span v-else>-</span>
      </template>
      <template #contractNo="{ row }">
        <Button v-if="row.contractId" type="link" @click="handleContractDetail(row)">
          {{ row.contractNo }}
        </Button>
        <span v-else>-</span>
      </template>
      <template #status="{ row }">
        <Tag v-if="row.status === QUOTATION_STATUS.DRAFT" color="default">草稿</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.CONVERTED" color="success">已转合同</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:sale-quotation:update'],
              onClick: handleEdit.bind(null, row),
              ifShow: row.status === QUOTATION_STATUS.DRAFT,
            },
            {
              label: '转合同',
              type: 'link',
              auth: ['crm:sale-quotation:update'],
              onClick: handleConvertContract.bind(null, row),
              ifShow: row.status === QUOTATION_STATUS.DRAFT && !row.contractId,
            },
            {
              label: '查看合同',
              type: 'link',
              onClick: handleContractDetail.bind(null, row),
              ifShow: !!row.contractId,
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              auth: ['crm:sale-quotation:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
              ifShow: row.status === QUOTATION_STATUS.DRAFT,
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/crm/saleQuotation/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { CrmSaleQuotationApi } from '#/api/crm/saleQuotation';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { erpPriceMultiply } from '#/packages/utils/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createSaleQuotation,
  getSaleQuotation,
  updateSaleQuotation,
} from '#/api/crm/saleQuotation';
import { $t } from '#/locales';
import ItemForm from './item-form.vue';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmSaleQuotationApi.SaleQuotation>();
const itemFormRef = ref();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['报价单'])
    : $t('ui.actionTitle.create', ['报价单']);
});
const isEdit = computed(() => !!formData.value?.id);
const isDisabled = computed(() => isEdit.value && formData.value?.status !== 0);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-3',
  layout: 'vertical',
  schema: useFormSchema(),
  showDefaultActions: false,
});
/** è®¡ç®—物料总金额 */
async function calculateTotalPrice() {
  const items = itemFormRef.value?.getData() || [];
  const totalProductPrice = items.reduce(
    (prev: number, curr: any) => prev + (curr.totalPrice || 0),
    0,
  );
  await formApi.setFieldValue('totalProductPrice', totalProductPrice);
  const discountPercent = (await formApi.getValues())?.discountPercent || 0;
  const discountPrice = erpPriceMultiply(totalProductPrice, discountPercent / 100) ?? 0;
  await formApi.setFieldValue('totalPrice', totalProductPrice - discountPrice);
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    try {
      await itemFormRef.value?.validate();
    } catch (e: any) {
      message.error(e.message);
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as CrmSaleQuotationApi.SaleQuotation;
    data.items = itemFormRef.value?.getData();
    try {
      await (formData.value?.id ? updateSaleQuotation(data) : createSaleQuotation(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    const data = modalApi.getData<CrmSaleQuotationApi.SaleQuotation>();
    if (!data || !data.id) {
      formData.value = {} as CrmSaleQuotationApi.SaleQuotation;
      await formApi.resetForm();
      // æ–°å¢žæ—¶ï¼Œé‡ç½®å¹¶æ·»åŠ ä¸€è¡Œç©ºæ•°æ®
      itemFormRef.value?.resetData();
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getSaleQuotation(data.id);
      await formApi.setValues(formData.value);
      // ç¼–辑时设置物料数据
      itemFormRef.value?.setData(formData.value.items || []);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[80%]">
    <Form class="mx-4">
      <template #items>
        <ItemForm
          ref="itemFormRef"
          class="w-full"
          :disabled="isDisabled"
          @change="calculateTotalPrice"
        />
      </template>
    </Form>
  </Modal>
</template>
src/views/crm/saleQuotation/modules/item-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
<script lang="ts" setup>
import type { MdmItemApi } from '#/api/mdm/item';
import type { CrmSaleQuotationApi } from '#/api/crm/saleQuotation';
import { computed, onMounted, ref } from 'vue';
import { erpPriceMultiply } from '#/packages/utils/src';
import { InputNumber, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getItemPage } from '#/api/mdm/item';
import { useItemColumns } from '../data';
interface Props {
  disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  disabled: false,
});
const emit = defineEmits(['change']);
const tableData = ref<CrmSaleQuotationApi.SaleQuotationItem[]>([]);
const itemOptions = ref<MdmItemApi.Item[]>([]);
/** èŽ·å–è¡¨æ ¼åˆè®¡æ•°æ® */
const summaries = computed(() => {
  return tableData.value.reduce(
    (acc, item) => {
      acc.count += item.count || 0;
      acc.totalPrice += item.totalPrice || 0;
      return acc;
    },
    { count: 0, totalPrice: 0 },
  );
});
/** è¡¨æ ¼é…ç½® */
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useItemColumns(),
    data: [],
    minHeight: 250,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'seq',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
/** è§¦å‘变更事件 */
function triggerChange() {
  emit('change');
}
/** å¤„理新增 */
function handleAdd() {
  tableData.value.push({
    itemId: undefined,
    itemName: undefined,
    itemCode: undefined,
    itemBarCode: undefined,
    itemSpecification: undefined,
    itemUnitName: undefined,
    itemUnitName2: undefined,
    itemUnitName3: undefined,
    itemPrice: undefined,
    quotationPrice: undefined,
    count: 1,
    totalPrice: 0,
  });
  gridApi.grid.loadData(tableData.value);
}
/** å¤„理删除 */
function handleDelete(row: CrmSaleQuotationApi.SaleQuotationItem) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index !== -1) {
    tableData.value.splice(index, 1);
    gridApi.grid.loadData(tableData.value);
    triggerChange();
  }
}
/** å¤„理物料变更 */
function handleItemChange(itemId: number, row: any) {
  const item = itemOptions.value.find((p) => p.id === itemId);
  if (!item) {
    return;
  }
  row.itemId = itemId;
  row.itemName = item.name;
  row.itemCode = item.code;
  row.itemBarCode = item.barCode;
  row.itemSpecification = item.specification;
  row.itemUnitName = item.unitMeasureName;
  row.itemUnitName2 = item.unitMeasureName2;
  row.itemUnitName3 = item.unitMeasureName3;
  row.itemPrice = item.salesPrice;
  row.quotationPrice = item.salesPrice;
  row.totalPrice = erpPriceMultiply(row.quotationPrice, row.count) ?? 0;
  triggerChange();
}
/** å¤„理报价变更 */
function handleQuotationPriceChange(value: number, row: any) {
  row.quotationPrice = value;
  row.totalPrice = erpPriceMultiply(value, row.count) ?? 0;
  triggerChange();
}
/** å¤„理数量变更 */
function handleCountChange(value: number, row: any) {
  row.count = value;
  row.totalPrice = erpPriceMultiply(row.quotationPrice, value) ?? 0;
  triggerChange();
}
/** è¡¨å•校验 */
function validate() {
  for (let i = 0; i < tableData.value.length; i++) {
    const item = tableData.value[i];
    if (item) {
      if (!item.itemId) {
        throw new Error(`第 ${i + 1} è¡Œï¼šç‰©æ–™ä¸èƒ½ä¸ºç©º`);
      }
      if (!item.count || item.count <= 0) {
        throw new Error(`第 ${i + 1} è¡Œï¼šæ•°é‡ä¸èƒ½ä¸ºç©º`);
      }
      if (!item.quotationPrice || item.quotationPrice <= 0) {
        throw new Error(`第 ${i + 1} è¡Œï¼šæŠ¥ä»·ä¸èƒ½ä¸ºç©º`);
      }
    }
  }
}
/** è®¾ç½®æ•°æ®ï¼ˆä¾›çˆ¶ç»„件调用) */
function setData(items: CrmSaleQuotationApi.SaleQuotationItem[]) {
  items.forEach((item) => {
    if (item.quotationPrice && item.count) {
      item.totalPrice = erpPriceMultiply(item.quotationPrice, item.count) ?? 0;
    }
  });
  tableData.value = [...items];
  gridApi.grid.loadData(tableData.value);
}
/** èŽ·å–æ•°æ® */
function getData() {
  return [...tableData.value];
}
/** é‡ç½®æ•°æ® */
function resetData() {
  tableData.value = [];
  handleAdd();
}
defineExpose({
  validate,
  setData,
  getData,
  resetData,
});
/** åˆå§‹åŒ– */
onMounted(async () => {
  const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
  itemOptions.value = res.list || [];
});
</script>
<template>
  <Grid class="w-full">
    <template #itemId="{ row }">
      <Select
        v-if="!disabled"
        v-model:value="row.itemId"
        :options="itemOptions"
        :field-names="{ label: 'name', value: 'id' }"
        class="w-full"
        placeholder="请选择物料"
        @change="(val) => handleItemChange(val, row)"
      />
      <span v-else>{{ row.itemName || '-' }}</span>
    </template>
    <template #quotationPrice="{ row }">
      <InputNumber
        v-if="!disabled"
        v-model:value="row.quotationPrice"
        :min="0"
        :precision="2"
        @change="(val) => handleQuotationPriceChange(val, row)"
      />
      <span v-else>{{ row.quotationPrice || '-' }}</span>
    </template>
    <template #count="{ row }">
      <InputNumber
        v-if="!disabled"
        v-model:value="row.count"
        :min="0.001"
        :precision="3"
        @change="(val) => handleCountChange(val, row)"
      />
      <span v-else>{{ row.count || '-' }}</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        v-if="!disabled"
        :actions="[
          {
            label: '删除',
            type: 'link',
            danger: true,
            popConfirm: {
              title: '确认删除该物料吗?',
              confirm: handleDelete.bind(null, row),
            },
          },
        ]"
      />
    </template>
    <template #bottom>
      <div class="mt-2 rounded border border-border bg-muted p-2">
        <div class="flex justify-between text-sm text-muted-foreground">
          <span class="font-medium text-foreground">合计:</span>
          <div class="flex space-x-4">
            <span>数量:{{ summaries.count }}</span>
            <span>金额:{{ summaries.totalPrice.toFixed(2) }}元</span>
          </div>
        </div>
      </div>
      <TableAction
        v-if="!disabled"
        class="mt-2 flex justify-center"
        :actions="[
          {
            label: '添加物料',
            type: 'default',
            onClick: handleAdd,
          },
        ]"
      />
    </template>
  </Grid>
</template>
src/views/erp/sale/order/data.ts
@@ -7,8 +7,8 @@
import { z } from '#/adapter/form';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getCustomerSimpleList } from '#/api/erp/sale/customer';
import { getItemPage } from '#/api/mdm/item';
import { getCustomerSimpleList as getCrmCustomerSimpleList } from '#/api/crm/customer';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -55,7 +55,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
@@ -192,6 +192,11 @@
      slots: { default: 'productId' },
    },
    {
      field: 'productSpecification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'stockCount',
      title: '库存',
      minWidth: 80,
@@ -205,6 +210,16 @@
    {
      field: 'productUnitName',
      title: '单位',
      minWidth: 80,
    },
    {
      field: 'productUnitName2',
      title: '辅单位1',
      minWidth: 80,
    },
    {
      field: 'productUnitName3',
      title: '辅单位2',
      minWidth: 80,
    },
    {
@@ -285,7 +300,10 @@
        placeholder: '请选择产品',
        allowClear: true,
        showSearch: true,
        api: getProductSimpleList,
        api: async () => {
          const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
@@ -307,7 +325,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
@@ -401,6 +419,12 @@
      minWidth: 120,
    },
    {
      field: 'contractNo',
      title: '关联合同',
      minWidth: 150,
      slots: { default: 'contractNo' },
    },
    {
      field: 'orderTime',
      title: '订单时间',
      width: 160,
src/views/erp/sale/order/index.vue
@@ -3,6 +3,7 @@
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '../../../../packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '../../../../packages/utils/src';
@@ -23,6 +24,8 @@
/** ERP é”€å”®è®¢å•列表 */
defineOptions({ name: 'ErpSaleOrder' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -97,6 +100,16 @@
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** æŸ¥çœ‹å…³è”合同 */
function handleViewContract(row: ErpSaleOrderApi.SaleOrder) {
  if (row.contractId) {
    router.push({
      path: '/crm/contract/detail',
      query: { id: row.contractId },
    });
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -167,6 +180,12 @@
          ]"
        />
      </template>
      <template #contractNo="{ row }">
        <a v-if="row.contractId" class="text-primary cursor-pointer" @click="handleViewContract(row)">
          {{ row.contractNo }}
        </a>
        <span v-else>-</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
src/views/erp/sale/order/modules/item-form.vue
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ErpProductApi } from '#/api/erp/product/product';
import type { MdmItemApi } from '#/api/mdm/item';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
@@ -13,7 +13,7 @@
import { Input, InputNumber, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getItemPage } from '#/api/mdm/item';
import { getStockCount } from '#/api/erp/stock/stock';
import { useFormItemColumns } from '../data';
@@ -37,7 +37,7 @@
]);
const tableData = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); // è¡¨æ ¼æ•°æ®
const productOptions = ref<ErpProductApi.Product[]>([]); // äº§å“ä¸‹æ‹‰é€‰é¡¹
const productOptions = ref<MdmItemApi.Item[]>([]); // ç‰©æ–™ä¸‹æ‹‰é€‰é¡¹
/** èŽ·å–è¡¨æ ¼åˆè®¡æ•°æ® */
const summaries = computed(() => {
@@ -158,12 +158,15 @@
    return;
  }
  row.productId = productId;
  row.productUnitId = product.unitId;
  row.productUnitId = product.unitMeasureId;
  row.productBarCode = product.barCode;
  row.productUnitName = product.unitName;
  row.productUnitName = product.unitMeasureName;
  row.productUnitName2 = product.unitMeasureName2;
  row.productUnitName3 = product.unitMeasureName3;
  row.productSpecification = product.specification;
  row.productName = product.name;
  row.stockCount = (await getStockCount(productId)) || 0;
  row.productPrice = product.salePrice || 0;
  row.productPrice = product.salesPrice || 0;
  row.count = row.count || 1;
  handleRowChange(row);
}
@@ -213,7 +216,8 @@
/** åˆå§‹åŒ– */
onMounted(async () => {
  productOptions.value = await getProductSimpleList();
  const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
  productOptions.value = res.list || [];
  // ç›®çš„:新增时,默认添加一行
  if (tableData.value.length === 0) {
    handleAdd();
src/views/erp/sale/out/data.ts
@@ -7,8 +7,8 @@
import { z } from '#/adapter/form';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getCustomerSimpleList } from '#/api/erp/sale/customer';
import { getItemPage } from '#/api/mdm/item';
import { getCustomerSimpleList as getCrmCustomerSimpleList } from '#/api/crm/customer';
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -69,7 +69,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        fieldNames: {
          label: 'name',
          value: 'id',
@@ -350,11 +350,12 @@
        placeholder: '请选择产品',
        allowClear: true,
        showSearch: true,
        api: getProductSimpleList,
        fieldNames: {
          label: 'name',
          value: 'id',
        api: async () => {
          const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
    },
    {
@@ -374,7 +375,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        fieldNames: {
          label: 'name',
          value: 'id',
@@ -554,7 +555,10 @@
        placeholder: '请选择产品',
        allowClear: true,
        showSearch: true,
        api: getProductSimpleList,
        api: async () => {
          const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
@@ -572,6 +576,8 @@
}
/** åˆ—表的字段 */
/** åˆ—表的字段 */
export function useOrderGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
src/views/erp/sale/out/modules/item-form.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { MdmItemApi } from '#/api/mdm/item';
import type { ErpSaleOutApi } from '#/api/erp/sale/out';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
@@ -12,7 +13,7 @@
import { Input, InputNumber, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getItemPage } from '#/api/mdm/item';
import { getWarehouseStockCount } from '#/api/erp/stock/stock';
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
@@ -40,7 +41,7 @@
]);
const tableData = ref<ErpSaleOutApi.SaleOutItem[]>([]); // è¡¨æ ¼æ•°æ®
const productOptions = ref<any[]>([]); // äº§å“ä¸‹æ‹‰é€‰é¡¹
const productOptions = ref<MdmItemApi.Item[]>([]); // ç‰©æ–™ä¸‹æ‹‰é€‰é¡¹
const warehouseOptions = ref<any[]>([]); // ä»“库下拉选项
/** èŽ·å–è¡¨æ ¼åˆè®¡æ•°æ® */
@@ -192,7 +193,8 @@
/** åˆå§‹åŒ– */
onMounted(async () => {
  productOptions.value = await getProductSimpleList();
  const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
  productOptions.value = res.list || [];
  warehouseOptions.value = await getWarehouseSimpleList();
});
</script>
src/views/erp/sale/return/data.ts
@@ -7,8 +7,8 @@
import { z } from '#/adapter/form';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getCustomerSimpleList } from '#/api/erp/sale/customer';
import { getItemPage } from '#/api/mdm/item';
import { getCustomerSimpleList as getCrmCustomerSimpleList } from '#/api/crm/customer';
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
@@ -69,7 +69,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
@@ -343,7 +343,10 @@
        placeholder: '请选择产品',
        allowClear: true,
        showSearch: true,
        api: getProductSimpleList,
        api: async () => {
          const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
@@ -365,7 +368,7 @@
        placeholder: '请选择客户',
        allowClear: true,
        showSearch: true,
        api: getCustomerSimpleList,
        api: getCrmCustomerSimpleList,
        labelField: 'name',
        valueField: 'id',
      },
@@ -541,7 +544,10 @@
        placeholder: '请选择产品',
        allowClear: true,
        showSearch: true,
        api: getProductSimpleList,
        api: async () => {
          const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
          return res.list || [];
        },
        labelField: 'name',
        valueField: 'id',
      },
src/views/erp/sale/return/modules/item-form.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { MdmItemApi } from '#/api/mdm/item';
import type { ErpSaleReturnApi } from '#/api/erp/sale/return';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
@@ -12,7 +13,7 @@
import { Input, InputNumber, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProductSimpleList } from '#/api/erp/product/product';
import { getItemPage } from '#/api/mdm/item';
import { getWarehouseStockCount } from '#/api/erp/stock/stock';
import { getWarehouseSimpleList } from '#/api/erp/stock/warehouse';
@@ -40,7 +41,7 @@
]);
const tableData = ref<ErpSaleReturnApi.SaleReturnItem[]>([]); // è¡¨æ ¼æ•°æ®
const productOptions = ref<any[]>([]); // äº§å“ä¸‹æ‹‰é€‰é¡¹
const productOptions = ref<MdmItemApi.Item[]>([]); // ç‰©æ–™ä¸‹æ‹‰é€‰é¡¹
const warehouseOptions = ref<any[]>([]); // ä»“库下拉选项
/** èŽ·å–è¡¨æ ¼åˆè®¡æ•°æ® */
@@ -192,7 +193,8 @@
/** åˆå§‹åŒ– */
onMounted(async () => {
  productOptions.value = await getProductSimpleList();
  const res = await getItemPage({ pageNo: 1, pageSize: 100, status: 0 });
  productOptions.value = res.list || [];
  warehouseOptions.value = await getWarehouseSimpleList();
});
</script>