feat: 发票导入、质检批量导入与检测台账、回款必填、报价单号提示、退货物料搜索

- CRM:发票批量导入弹窗、合同导入模板、回款必填、报价单号提示
- MES:IQC/IPQC/OQC/RQC 批量导入弹窗与接口、检测结果台账页面
- WLS:退货申请明细物料下拉支持搜索
已添加6个文件
已修改17个文件
947 ■■■■■ 文件已修改
docs/合同导入模板.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/invoice/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ipqc/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/iqc/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ledger/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/oqc/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/rqc/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/modules/import-form.vue 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/import-form-modal.vue 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/index.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ledger/data.ts 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ledger/index.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/index.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/returnsales/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/ºÏͬµ¼ÈëÄ£°å.xlsx
Binary files differ
src/api/crm/invoice/index.ts
@@ -120,3 +120,23 @@
    `/crm/invoice/simple-list?contractId=${contractId}`,
  );
}
/** å¼€ç¥¨å¯¼å…¥ç»“æžœ */
export interface InvoiceImportResult {
  /** åˆ›å»ºæˆåŠŸçš„å¼€ç¥¨ç¼–å· */
  createList?: string[];
  /** å¯¼å…¥å¤±è´¥çš„集合:key ä¸ºã€Œå‘票号码 xxx」或「第 N è¡Œã€ */
  failureList?: Record<string, string>;
}
/** ä¸‹è½½å¼€ç¥¨å¯¼å…¥æ¨¡æ¿ */
export function importInvoiceTemplate() {
  return requestClient.download('/crm/invoice/get-import-template');
}
/** å¯¼å…¥å¼€ç¥¨ */
export function importInvoice(file: File) {
  return requestClient.upload<InvoiceImportResult>('/crm/invoice/import', {
    file,
  });
}
src/api/mes/qc/ipqc/index.ts
@@ -40,11 +40,18 @@
    majorQuantity?: number; // ä¸¥é‡ç¼ºé™·æ•°é‡
    minorQuantity?: number; // è½»å¾®ç¼ºé™·æ•°é‡
    checkResult?: number; // æ£€æµ‹ç»“æžœ
    indicatorResultSummary?: string; // æ£€æµ‹ç»“果(检验项实测值汇总,如「温度:15,电阻:1」)
    inspectDate?: number; // æ£€æµ‹æ—¥æœŸ
    inspectorUserId?: number; // æ£€æµ‹äººå‘˜ç”¨æˆ· ID
    inspectorNickname?: string; // æ£€æµ‹äººå‘˜æ˜µç§°ï¼ˆå…³è”查询)
    status?: number; // çŠ¶æ€
    remark?: string; // å¤‡æ³¨
  }
  /** è¿‡ç¨‹æ£€éªŒå•导入结果 */
  export interface ImportResult {
    createList?: string[]; // æ–°å¢žæˆåŠŸçš„æ£€éªŒå•ç¼–å·
    failureList?: Record<string, string>; // å¯¼å…¥å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œæ£€éªŒå•编号 X」或「第 N è¡Œã€
  }
}
@@ -84,3 +91,15 @@
export function exportIpqc(params: any) {
  return requestClient.download('/mes/qc/ipqc/export-excel', { params });
}
/** ä¸‹è½½è¿‡ç¨‹æ£€éªŒå•导入模板 */
export function importIpqcTemplate() {
  return requestClient.download('/mes/qc/ipqc/get-import-template');
}
/** å¯¼å…¥è¿‡ç¨‹æ£€éªŒå• */
export function importIpqc(file: File) {
  return requestClient.upload<MesQcIpqcApi.ImportResult>('/mes/qc/ipqc/import', {
    file,
  });
}
src/api/mes/qc/iqc/index.ts
@@ -32,6 +32,7 @@
    majorQuantity?: number; // ä¸¥é‡ç¼ºé™·æ•°é‡
    minorQuantity?: number; // è½»å¾®ç¼ºé™·æ•°é‡
    checkResult?: number; // æ£€æµ‹ç»“æžœ
    indicatorResultSummary?: string; // æ£€æµ‹ç»“果(检验项实测值汇总,如「温度:15,电阻:1」)
    receiveDate?: number; // æ¥æ–™æ—¥æœŸ
    inspectDate?: number; // æ£€æµ‹æ—¥æœŸ
    inspector?: string; // æ£€æµ‹äººå‘˜ï¼ˆæ˜µç§°ï¼‰
@@ -39,6 +40,12 @@
    inspectorNickname?: string; // æ£€æµ‹äººå‘˜æ˜µç§°ï¼ˆå…³è”查询)
    status?: number; // çŠ¶æ€
    remark?: string; // å¤‡æ³¨
  }
  /** æ¥æ–™æ£€éªŒå•导入结果 */
  export interface ImportResult {
    createList?: string[]; // æ–°å¢žæˆåŠŸçš„æ£€éªŒå•ç¼–å·
    failureList?: Record<string, string>; // å¯¼å…¥å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œæ£€éªŒå•编号 X」或「第 N è¡Œã€
  }
}
@@ -78,3 +85,15 @@
export function exportIqc(params: any) {
  return requestClient.download('/mes/qc/iqc/export-excel', { params });
}
/** ä¸‹è½½æ¥æ–™æ£€éªŒå•导入模板 */
export function importIqcTemplate() {
  return requestClient.download('/mes/qc/iqc/get-import-template');
}
/** å¯¼å…¥æ¥æ–™æ£€éªŒå• */
export function importIqc(file: File) {
  return requestClient.upload<MesQcIqcApi.ImportResult>('/mes/qc/iqc/import', {
    file,
  });
}
src/api/mes/qc/ledger/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesQcInspectionLedgerApi {
  /** MES æ£€æµ‹ç»“果台账(一行一个检测项结果;检验单未录入检测项时该组字段为空) */
  export interface Ledger {
    qcType?: number; // åˆ†ç±»ï¼ˆæ£€éªŒç±»åž‹ï¼‰ï¼š1 æ¥æ–™æ£€éªŒ / 2 è¿‡ç¨‹æ£€éªŒ / 3 å‡ºè´§æ£€éªŒ / 4 é€€è´§æ£€éªŒ
    qcId?: number; // æ£€éªŒå• ID
    code?: string; // æ£€éªŒå•编号
    name?: string; // æ£€éªŒå•名称
    status?: number; // å•据状态
    itemId?: number; // ç‰©æ–™ç¼–号
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
    itemSpecification?: string; // è§„格型号
    unitName?: string; // è®¡é‡å•位名称
    batchNo?: string; // æ‰¹æ¬¡å·
    checkQuantity?: number; // æ£€æµ‹æ•°é‡
    qualifiedQuantity?: number; // åˆæ ¼æ•°é‡
    unqualifiedQuantity?: number; // ä¸åˆæ ¼æ•°é‡
    checkResult?: number; // æ£€æµ‹ç»“æžœ
    inspectDate?: string; // æ£€æµ‹æ—¥æœŸ
    inspectorUserId?: number; // æ£€æµ‹äººå‘˜ç¼–号
    inspectorName?: string; // æ£€æµ‹äººå‘˜æ˜µç§°
    indicatorId?: number; // æ£€æµ‹é¡¹ç¼–号
    indicatorName?: string; // æ£€æµ‹é¡¹åç§°
    indicatorValue?: string; // æ£€æµ‹å€¼ï¼ˆå®žæµ‹å€¼ï¼‰
    indicatorRemark?: string; // æ£€æµ‹é¡¹å¤‡æ³¨
    judgementBasis?: string; // åˆ¤å®šä¾æ®ï¼Œå¦‚ 10~20、≤20、≥10、标准值 15
    remark?: string; // æ£€éªŒå•备注
  }
}
/** æŸ¥è¯¢æ£€æµ‹ç»“果台账分页(四类检验单统一查询) */
export function getInspectionLedgerPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcInspectionLedgerApi.Ledger>>(
    '/mes/qc/inspection-ledger/page',
    { params },
  );
}
/** å¯¼å‡ºæ£€æµ‹ç»“果台账 */
export function exportInspectionLedger(params: Record<string, unknown>) {
  return requestClient.download('/mes/qc/inspection-ledger/export-excel', {
    params,
  });
}
src/api/mes/qc/oqc/index.ts
@@ -34,12 +34,19 @@
    majorQuantity?: number; // ä¸¥é‡ç¼ºé™·æ•°é‡
    minorQuantity?: number; // è½»å¾®ç¼ºé™·æ•°é‡
    checkResult?: number; // æ£€æµ‹ç»“æžœ
    indicatorResultSummary?: string; // æ£€æµ‹ç»“果(检验项实测值汇总,如「温度:15,电阻:1」)
    outDate?: number; // å‡ºè´§æ—¥æœŸ
    inspectDate?: number; // æ£€æµ‹æ—¥æœŸ
    inspectorUserId?: number; // æ£€æµ‹äººå‘˜ç”¨æˆ· ID
    inspectorNickname?: string; // æ£€æµ‹äººå‘˜æ˜µç§°ï¼ˆå…³è”查询)
    status?: number; // çŠ¶æ€
    remark?: string; // å¤‡æ³¨
  }
  /** å‡ºè´§æ£€éªŒå•导入结果 */
  export interface ImportResult {
    createList?: string[]; // æ–°å¢žæˆåŠŸçš„æ£€éªŒå•ç¼–å·
    failureList?: Record<string, string>; // å¯¼å…¥å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œæ£€éªŒå•编号 X」或「第 N è¡Œã€
  }
}
@@ -79,3 +86,15 @@
export function exportOqc(params: any) {
  return requestClient.download('/mes/qc/oqc/export-excel', { params });
}
/** ä¸‹è½½å‡ºè´§æ£€éªŒå•导入模板 */
export function importOqcTemplate() {
  return requestClient.download('/mes/qc/oqc/get-import-template');
}
/** å¯¼å…¥å‡ºè´§æ£€éªŒå• */
export function importOqc(file: File) {
  return requestClient.upload<MesQcOqcApi.ImportResult>('/mes/qc/oqc/import', {
    file,
  });
}
src/api/mes/qc/rqc/index.ts
@@ -24,6 +24,7 @@
    qualifiedQuantity?: number; // åˆæ ¼å“æ•°é‡
    unqualifiedQuantity?: number; // ä¸åˆæ ¼æ•°é‡
    checkResult?: number; // æ£€æµ‹ç»“æžœ
    indicatorResultSummary?: string; // æ£€æµ‹ç»“果(检验项实测值汇总,如「温度:15,电阻:1」)
    inspectDate?: number; // æ£€æµ‹æ—¥æœŸ
    inspectorUserId?: number; // æ£€æµ‹äººå‘˜ç”¨æˆ· ID
    inspectorNickname?: string; // æ£€æµ‹äººå‘˜æ˜µç§°ï¼ˆå…³è”查询)
src/views/crm/invoice/index.vue
@@ -21,6 +21,7 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
const { push } = useRouter();
const sceneType = ref('1');
@@ -29,6 +30,16 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
/** æ‰“开导入开票弹窗 */
function handleImport() {
  importModalApi.open();
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -149,6 +160,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-actions>
        <Tabs class="w-full" @change="handleChangeSceneType">
@@ -168,6 +180,13 @@
              onClick: handleCreate,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['crm:invoice:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/crm/invoice/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { InvoiceImportResult } from '#/api/crm/invoice';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Upload } from 'ant-design-vue';
import { importInvoice, importInvoiceTemplate } from '#/api/crm/invoice';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const importResult = ref<InvoiceImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importInvoice(fileList.value[0] as File);
      // å®Œæˆï¼šè¿›åº¦æ¡èµ°æ»¡
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(
        importResult.value?.failureList ?? {},
      ).length;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importInvoiceTemplate();
  downloadFileFromBlobPart({ fileName: '开票导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
</script>
<template>
  <Modal title="导入开票" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:合同编号、发票类型、开票金额(元)。“合同编号”需与系统中已审核通过的合同编号一致(编号需完全一致),客户由合同自动带入,无需填写;“发票类型”按字典名称填写(增值税专用发票 / å¢žå€¼ç¨Žæ™®é€šå‘票 / ç”µå­å‘票);“开票金额(元)”需大于 0 ä¸”不超过该合同剩余可开票金额。</p>
        <p>“开票编号”留空则由系统自动生成,填写时不可与系统中已有开票编号重复;“开票日期”格式为 2026-01-01 10:00:00 æˆ– 2026-01-01,留空取当前时间。</p>
        <p>逐行导入,失败行不影响成功行;导入的开票负责人 = å½“前登录用户,状态为“未提交”,可在列表中继续编辑、上传发票附件后提交审批。</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">
          æ­£åœ¨å¯¼å…¥å¼€ç¥¨æ•°æ®ï¼Œè¯·ç¨å€™...
        </div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} æ¡</span>
          <span class="text-red-600">失败:{{ failureCount }} æ¡</span>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/crm/receivable/data.ts
@@ -119,7 +119,7 @@
      fieldName: 'invoiceId',
      label: '关联开票',
      component: 'Select',
      rules: 'required',
      help: '可不选:不选表示该笔回款暂不关联开票',
      dependencies: {
        triggerFields: ['contractId', 'invoiceId'],
        disabled: (values) => !values.contractId || !!values.id,
@@ -132,6 +132,7 @@
          }
          const invoices = await getInvoiceSimpleList(values.contractId);
          return {
            allowClear: true,
            options: invoices.map((item) => {
              const remain = item.remainingInvoicePrice ?? item.price ?? 0;
              return {
@@ -139,11 +140,12 @@
                value: item.id,
              };
            }),
            placeholder: '请选择关联开票',
            placeholder: '请选择关联开票(可不选)',
            onChange: (value: number) => {
              form.setFieldValue('planId', undefined);
              form.setFieldValue('returnTime', undefined);
              form.setFieldValue('returnType', undefined);
              form.setFieldValue('price', undefined);
              const invoice = invoices.find((item) => item.id === value);
              if (invoice) {
                form.setFieldValue(
@@ -161,8 +163,7 @@
      fieldName: 'planId',
      label: '回款期数',
      component: 'Select',
      rules: 'required',
      help: '此参数可在回款计划配置',
      help: '此参数可在回款计划配置;可不选,不选则不占用回款计划期数',
      dependencies: {
        triggerFields: ['contractId'],
        disabled: (values) => !values.contractId || values.id,
@@ -178,12 +179,13 @@
            values.contractId,
          );
          return {
            allowClear: true,
            options: plans.map((item) => ({
              disabled: !!item.receivableId,
              label: `第 ${item.period} æœŸ`,
              value: item.id,
            })),
            placeholder: '请选择回款期数',
            placeholder: '请选择回款期数(可不选)',
            onChange: async (value: any) => {
              if (!value) {
                form.setFieldValue('returnTime', undefined);
src/views/crm/receivable/modules/form.vue
@@ -147,7 +147,7 @@
    <Alert
      v-if="showInvoiceHint"
      type="warning"
      message="请先完成开票并上传发票附件,再进行回款操作"
      message="如需关联开票,请先完成开票并上传发票附件,再进行回款操作;【关联开票】【回款期数】均为选填,不选可直接填写回款信息"
      show-icon
      class="mb-4"
    />
src/views/crm/saleQuotation/data.ts
@@ -34,9 +34,10 @@
      fieldName: 'no',
      label: '报价单号',
      component: 'Input',
      help: '留空由系统自动生成;填写则使用该编号,需全库唯一',
      componentProps: {
        placeholder: '保存时自动生成',
        disabled: true,
        placeholder: '报价单号自定义',
        maxLength: 32,
      },
    },
    {
src/views/mes/qc/components/import-form-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Progress, Upload } from 'ant-design-vue';
/** å¯¼å…¥ç»“果(IQC/IPQC/OQC ä¸‰ä¸ªå¯¼å…¥æŽ¥å£è¿”回结构一致) */
interface ImportResult {
  createList?: string[];
  failureList?: Record<string, string>;
}
interface ImportModalData {
  /** å¼¹çª—标题 */
  title?: string;
  /** æ¨¡æ¿ä¸‹è½½å‡½æ•°ï¼šrequestClient.download(...) è¿”回 Blob */
  templateApi: () => Promise<Blob>;
  /** å¯¼å…¥å‡½æ•°ï¼ˆmultipart ä¸Šä¼  Excel) */
  importApi: (file: File) => Promise<ImportResult>;
  /** ä¸‹è½½çš„æ¨¡æ¿æ–‡ä»¶å */
  fileName: string;
  /** ç°è‰²æç¤ºåŒºçš„说明段落 */
  hints?: string[];
}
defineOptions({ name: 'QcImportFormModal' });
const emit = defineEmits(['success']);
const modalData = ref<ImportModalData>();
const title = ref('批量导入');
const hints = ref<string[]>([]);
const fileList = ref<FileType[]>([]);
const importResult = ref<ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const createCount = computed(() => importResult.value?.createList?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const data = modalData.value;
    if (!data) {
      return;
    }
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) {
      return;
    }
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    let shouldClose = false;
    try {
      importResult.value = await data.importApi(fileList.value[0] as File);
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      if (failureCount.value > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failureCount.value} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      modalData.value = undefined;
      fileList.value = [];
      importResult.value = undefined;
      importing.value = false;
      progressPercent.value = 0;
      return;
    }
    const data = modalApi.getData<ImportModalData>();
    modalData.value = data;
    title.value = data?.title ?? '批量导入';
    hints.value = data?.hints ?? [];
    modalApi.setState({ confirmText: '导入' });
  },
});
/** ä¸Šä¼ å‰ï¼šåªä¿ç•™ä¸€ä¸ªæ–‡ä»¶ï¼Œäº¤ç”± onConfirm ç»Ÿä¸€æäº¤ */
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = modalData.value;
  if (!data) {
    return;
  }
  const blob = await data.templateApi();
  downloadFileFromBlobPart({ fileName: data.fileName, source: blob });
}
</script>
<template>
  <Modal :title="title" class="w-[720px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div
        v-if="hints.length > 0"
        class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500"
      >
        <p v-for="(hint, index) in hints" :key="index">{{ hint }}</p>
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">
          æ­£åœ¨å¯¼å…¥æ£€éªŒå•数据,请稍候...
        </div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} æ¡</span>
          <span class="text-red-600">失败:{{ failureCount }} æ¡</span>
        </div>
        <div v-if="createCount > 0" class="text-sm text-gray-600">
          æ–°å¢žæ£€éªŒå•为「待检验」状态,请在列表中编辑并录入检测结果
        </div>
        <div v-if="failureCount > 0" class="mt-2 max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
            :key="name"
            class="text-sm leading-6 text-red-500"
          >
            {{ name }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/mes/qc/ipqc/data.ts
@@ -513,6 +513,12 @@
      },
    },
    {
      field: 'indicatorResultSummary',
      title: '检测结果(检验项)',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'inspectDate',
      title: '检测日期',
      width: 120,
src/views/mes/qc/ipqc/index.vue
@@ -8,9 +8,16 @@
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteIpqc, exportIpqc, getIpqcPage } from '#/api/mes/qc/ipqc';
import {
  deleteIpqc,
  exportIpqc,
  getIpqcPage,
  importIpqc,
  importIpqcTemplate,
} from '#/api/mes/qc/ipqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcImportFormModal from '#/views/mes/qc/components/import-form-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -34,9 +41,34 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: QcImportFormModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ‰¹é‡å¯¼å…¥è¿‡ç¨‹æ£€éªŒå• */
function handleImport() {
  importModalApi
    .setData({
      title: '批量导入过程检验单',
      templateApi: importIpqcTemplate,
      importApi: importIpqc,
      fileName: '过程检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、检验类型、生产工单号、工位名称、合格品数量、不合格品数量。',
        '「产品物料由生产工单自动推导」,模板不填物料;「检验类型」按字典名称填写(自制工序检验 / é¦–检 / å·¡æ£€ / è‡ªæ£€ / æˆå“æ£€éªŒï¼‰ã€‚',
        '「生产工单号」需与「生产管理 - ç”Ÿäº§å·¥å•」中的工单号一致;「工位名称」需与「基础数据 - å·¥ä½ç®¡ç†ã€ä¸­çš„名称一致。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「检测数量」(留空时自动取两者之和);工废 + æ–™åºŸ + å…¶ä»–废品 ä¹‹å’Œä¸å¾—超过不合格品数量。',
        '「质检方案」填方案名称,留空则按物料自动匹配 IPQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;「检测日期」可留空。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
      ],
    })
    .open();
}
/** åˆ›å»ºè¿‡ç¨‹æ£€éªŒå• */
@@ -123,6 +155,7 @@
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="过程检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -135,6 +168,13 @@
              onClick: handleCreate,
            },
            {
              label: '批量导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:qc-ipqc:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/qc/iqc/data.ts
@@ -437,6 +437,12 @@
      },
    },
    {
      field: 'indicatorResultSummary',
      title: '检测结果(检验项)',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'receiveDate',
      title: '来料日期',
      width: 120,
src/views/mes/qc/iqc/index.vue
@@ -11,9 +11,16 @@
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteIqc, exportIqc, getIqcPage } from '#/api/mes/qc/iqc';
import {
  deleteIqc,
  exportIqc,
  getIqcPage,
  importIqc,
  importIqcTemplate,
} from '#/api/mes/qc/iqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcImportFormModal from '#/views/mes/qc/components/import-form-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -41,9 +48,33 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: QcImportFormModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ‰¹é‡å¯¼å…¥æ¥æ–™æ£€éªŒå• */
function handleImport() {
  importModalApi
    .setData({
      title: '批量导入来料检验单',
      templateApi: importIqcTemplate,
      importApi: importIqc,
      fileName: '来料检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、合格品数量、不合格品数量、检测日期、来料日期。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「供应商名称」需与「供应商管理」中的名称一致,留空则该单无供应商。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「本次接收数量」(留空时自动取两者之和);「检测结果」按字典名称填写(校验通过 / æ ¡éªŒä¸é€šè¿‡ï¼‰ã€‚',
        '「质检方案」填方案名称,留空则按物料自动匹配 IQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;日期支持 2026-01-01 10:00:00 æˆ– 2026-01-01。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
      ],
    })
    .open();
}
/** åˆ›å»ºæ¥æ–™æ£€éªŒå• */
@@ -137,6 +168,7 @@
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="来料检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -149,6 +181,13 @@
              onClick: handleCreate,
            },
            {
              label: '批量导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:qc-iqc:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/qc/ledger/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,200 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesQcInspectionLedgerApi } from '#/api/mes/qc/ledger';
import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'qcType',
      label: '分类',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_QC_TYPE, 'number'),
        placeholder: '请选择分类',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '检测日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
      },
    },
    {
      fieldName: 'code',
      label: '检验单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入检验单编号',
      },
    },
    {
      fieldName: 'itemId',
      label: '产品物料',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择产品物料',
      },
    },
    {
      fieldName: 'batchNo',
      label: '批次号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'checkResult',
      label: '检测结果',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_QC_CHECK_RESULT, 'number'),
        placeholder: '请选择检测结果',
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '检测人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择检测人员',
        valueField: 'id',
      },
    },
  ];
}
/** åˆ—表的字段(一行一个检测项结果) */
export function useGridColumns(): VxeTableGridOptions<MesQcInspectionLedgerApi.Ledger>['columns'] {
  return [
    {
      field: 'qcType',
      title: '分类',
      width: 130,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_QC_TYPE },
      },
    },
    {
      field: 'code',
      title: '检验单编号',
      width: 170,
    },
    {
      field: 'name',
      title: '检验单名称',
      minWidth: 180,
      showOverflow: 'tooltip',
    },
    {
      field: 'itemCode',
      title: '物料编码',
      width: 130,
    },
    {
      field: 'itemName',
      title: '物料名称',
      minWidth: 150,
      showOverflow: 'tooltip',
    },
    {
      field: 'itemSpecification',
      title: '规格型号',
      minWidth: 140,
      showOverflow: 'tooltip',
    },
    {
      field: 'batchNo',
      title: '批次号',
      width: 130,
    },
    {
      field: 'indicatorName',
      title: '检测项',
      minWidth: 140,
      showOverflow: 'tooltip',
    },
    {
      field: 'indicatorValue',
      title: '检测值',
      minWidth: 120,
      showOverflow: 'tooltip',
    },
    {
      field: 'judgementBasis',
      title: '判定依据',
      minWidth: 120,
      showOverflow: 'tooltip',
    },
    {
      field: 'checkResult',
      title: '检测结果',
      width: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_QC_CHECK_RESULT },
      },
    },
    {
      field: 'checkQuantity',
      title: '检测数量',
      width: 100,
    },
    {
      field: 'qualifiedQuantity',
      title: '合格数量',
      width: 100,
    },
    {
      field: 'unqualifiedQuantity',
      title: '不合格数量',
      width: 110,
    },
    {
      field: 'inspectorName',
      title: '检测人员',
      width: 110,
    },
    {
      field: 'inspectDate',
      title: '检测日期',
      width: 170,
      formatter: 'formatDateTime',
    },
    {
      field: 'status',
      title: '单据状态',
      width: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_ORDER_STATUS },
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 160,
      showOverflow: 'tooltip',
    },
  ];
}
src/views/mes/qc/ledger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesQcInspectionLedgerApi } from '#/api/mes/qc/ledger';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  exportInspectionLedger,
  getInspectionLedgerPage,
} from '#/api/mes/qc/ledger';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
/** å¯¼å‡ºæ£€æµ‹ç»“果台账(与列表共用同一套筛选条件) */
async function handleExport() {
  const formValues = await gridApi.formApi.getValues();
  const data = await exportInspectionLedger(formValues);
  downloadFileFromBlobPart({ fileName: '检测结果台账.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getInspectionLedgerPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    // å°è´¦è¡Œæ— å”¯ä¸€ id(同一检验单多行 qcId é‡å¤ï¼‰ï¼Œä¸è®¾ keyField,由 vxe å†…部生成行标识
    rowConfig: {
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesQcInspectionLedgerApi.Ledger>,
});
</script>
<template>
  <Page auto-content-height>
    <Grid table-title="检测结果台账">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:qc-inspection-ledger:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/qc/oqc/data.ts
@@ -449,6 +449,12 @@
      },
    },
    {
      field: 'indicatorResultSummary',
      title: '检测结果(检验项)',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'outDate',
      title: '出货日期',
      width: 120,
src/views/mes/qc/oqc/index.vue
@@ -8,9 +8,16 @@
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteOqc, exportOqc, getOqcPage } from '#/api/mes/qc/oqc';
import {
  deleteOqc,
  exportOqc,
  getOqcPage,
  importOqc,
  importOqcTemplate,
} from '#/api/mes/qc/oqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcImportFormModal from '#/views/mes/qc/components/import-form-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
@@ -34,9 +41,33 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: QcImportFormModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ‰¹é‡å¯¼å…¥å‡ºè´§æ£€éªŒå• */
function handleImport() {
  importModalApi
    .setData({
      title: '批量导入出货检验单',
      templateApi: importOqcTemplate,
      importApi: importOqc,
      fileName: '出货检验单导入模板.xls',
      hints: [
        '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›å¿…填列:检验单名称、产品物料编码、客户名称、本次出货数量、合格品数量、不合格品数量、检测日期、出货日期。',
        '「产品物料编码」需与「物料管理」中的编码完全一致;「客户名称」需与「CRM å®¢æˆ·ç®¡ç†ã€ä¸­çš„名称一致,且必须在当前登录用户的客户数据权限范围内。',
        '合格品数量 + ä¸åˆæ ¼å“æ•°é‡ å¿…须等于「检测数量」(留空时自动取两者之和);「检测结果」按字典名称填写(校验通过 / æ ¡éªŒä¸é€šè¿‡ï¼‰ã€‚',
        '「质检方案」填方案名称,留空则按物料自动匹配 OQC æ–¹æ¡ˆï¼›ã€Œæ£€æµ‹äººå‘˜ã€ç•™ç©ºå–当前登录用户;日期支持 2026-01-01 10:00:00 æˆ– 2026-01-01。',
        '逐行导入,失败行不影响成功行;导入后的检验单为「待检验」状态,可在列表中继续编辑、录入检测结果。',
      ],
    })
    .open();
}
/** åˆ›å»ºå‡ºè´§æ£€éªŒå• */
@@ -123,6 +154,7 @@
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="出货检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -135,6 +167,13 @@
              onClick: handleCreate,
            },
            {
              label: '批量导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:qc-oqc:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/qc/rqc/data.ts
@@ -433,6 +433,12 @@
      },
    },
    {
      field: 'indicatorResultSummary',
      title: '检测结果(检验项)',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'inspectDate',
      title: '检测日期',
      width: 160,
src/views/wls/returnsales/data.ts
@@ -362,6 +362,7 @@
              label: `${item.productName || ''}(${item.productBarCode || ''})`,
              value: item.productId,
            })),
            showSearch: true,
            onChange: (value: number | undefined) => {
              const selected = items.find((i) => i.productId === value);
              onItemChange?.(selected);