gaoluyang
3 天以前 971c936f801e76d94b2bd93457a32e3368531f5c
采购申请批量提交、订单导入重复弹框;修复考勤异常申请字段不匹配

- 采购订单导入:支持 overwrite 参数,检测到重复订单时弹框提醒,用户确认后再覆盖
- 采购申请:列表新增批量提交按钮,按 50 张分批调用批量提交接口,失败明细弹框展示
- 考勤异常申请:表单/列表/审批详情字段改为 exceptionDate、type,异常类型选项与后端枚举一致
- 补充提交图标

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改9个文件
381 ■■■■■ 文件已修改
src/api/erp/purchase/order/index.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/request/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/exception/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/icons.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/modules/import-form.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/index.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/modules/process-select-modal.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/data.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/exception/detail/approve/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/order/index.ts
@@ -112,6 +112,18 @@
  });
}
/** 采购订单导入重复项 */
export interface ImportDuplicate {
  /** 导入文件中的采购单编号;编号为空时为行号 */
  key: string;
  /** 系统内已存在的采购订单编号 */
  existingNo: string;
  /** 系统内已存在的采购订单编号 */
  existingId: number;
  /** 重复原因 */
  reason: string;
}
/** 采购订单导入结果 */
export interface ImportResult {
  /** 创建成功的采购单编号 */
@@ -120,6 +132,8 @@
  updateList?: string[];
  /** 导入失败集合,key 为采购单编号或行号,value 为失败原因 */
  failureList?: Record<string, string>;
  /** 检测到的重复订单;未开启覆盖时这些订单不会导入 */
  duplicateList?: ImportDuplicate[];
}
/** 获取采购审核流程列表响应 */
@@ -143,9 +157,13 @@
}
/** 导入采购订单 */
export function importPurchaseOrder(file: File, directApprove: boolean) {
export function importPurchaseOrder(
  file: File,
  directApprove: boolean,
  overwrite = false,
) {
  return requestClient.upload<ImportResult>(
    '/erp/purchase-order/import',
    { file, directApprove },
    { file, directApprove, overwrite },
  );
}
src/api/erp/purchase/request/index.ts
@@ -75,6 +75,26 @@
  return requestClient.put('/erp/purchase-request/submit', null, { params: { id, processDefinitionKey } });
}
/** 采购申请批量提交结果 */
export interface SubmitBatchResult {
  /** 提交成功的采购申请编号 */
  successIds?: number[];
  /** 提交失败集合,key 为采购申请单号(取不到单号时为编号),value 为失败原因 */
  failureMap?: Record<string, string>;
}
/** 批量提交采购申请审批 */
export function submitPurchaseRequestBatch(
  ids: number[],
  processDefinitionKey: string,
) {
  return requestClient.put<SubmitBatchResult>(
    '/erp/purchase-request/submit-batch',
    null,
    { params: { ids: ids.join(','), processDefinitionKey } },
  );
}
/** 重置被驳回的采购申请为草稿状态 */
export function resetPurchaseRequestStatus(id: number) {
  return requestClient.put('/erp/purchase-request/reset-status', null, { params: { id } });
src/api/hrm/attendance/exception/index.ts
@@ -10,9 +10,8 @@
    userId?: number;
    userName?: string;
    deptName?: string;
    date?: string;
    exceptionType?: number;
    exceptionTypeName?: string;
    exceptionDate?: string;
    type?: number;
    reason?: string;
    blobIds?: number[];
    attachmentList?: SystemStorageApi.StorageBlob[];
src/components/table-action/icons.ts
@@ -13,4 +13,5 @@
  CLOSE: 'lucide:x',
  BOOK: 'lucide:book',
  AUDIT: 'lucide:file-check',
  SUBMIT: 'lucide:send',
};
src/views/erp/purchase/order/modules/import-form.vue
@@ -1,13 +1,23 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { ImportResult } from '#/api/erp/purchase/order';
import type {
  ImportDuplicate,
  ImportResult,
} from '#/api/erp/purchase/order';
import { computed, ref } from 'vue';
import { computed, h, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Switch, Upload } from 'ant-design-vue';
import {
  Button,
  message,
  Modal as AntModal,
  Progress,
  Switch,
  Upload,
} from 'ant-design-vue';
import {
  importPurchaseOrder,
@@ -36,19 +46,35 @@
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importPurchaseOrder(
        fileList.value[0] as File,
        directApprove.value,
      );
      // 完成:进度条走满
      // 1. 先按“不覆盖”导入:重复订单不落库,返回给前端弹框确认
      let result = await runImport(false);
      stopMockProgress();
      progressPercent.value = 100;
      // 2. 检测到重复时弹框提醒,用户确认后才以覆盖模式重新导入
      const duplicates = result.duplicateList ?? [];
      if (duplicates.length > 0) {
        const overwrite = await askOverwrite(duplicates);
        if (overwrite) {
          startMockProgress();
          result = await runImport(true);
          stopMockProgress();
          progressPercent.value = 100;
        }
      }
      importResult.value = result;
      await delay(300);
      emit('success');
      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
      const failedCount = Object.keys(result.failureList ?? {}).length;
      const duplicateCount = result.duplicateList?.length ?? 0;
      if (failedCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} 条,失败 ${failedCount} 条,请查看下方失败明细`,
          `导入完成:新增 ${createCount.value} 张,覆盖更新 ${updateCount.value} 张,失败 ${failedCount} 张,请查看下方失败明细`,
        );
      } else if (duplicateCount > 0) {
        message.warning(
          `导入完成:新增 ${createCount.value} 张;${duplicateCount} 张重复订单未导入,可在下方选择覆盖`,
        );
      } else {
        message.success('导入完成');
@@ -64,6 +90,80 @@
    }
  },
});
function runImport(overwrite: boolean) {
  return importPurchaseOrder(
    fileList.value[0] as File,
    directApprove.value,
    overwrite,
  );
}
/** 重复订单弹框:确认后覆盖系统内已有订单 */
function askOverwrite(duplicates: ImportDuplicate[]) {
  return new Promise<boolean>((resolve) => {
    const lines = duplicates.slice(0, 10).map((item) =>
      h(
        'div',
        { class: 'text-sm leading-6' },
        `${item.key} → 已存在订单 ${item.existingNo}(${item.reason})`,
      ),
    );
    if (duplicates.length > 10) {
      lines.push(
        h(
          'div',
          { class: 'text-sm leading-6 text-gray-500' },
          `……另有 ${duplicates.length - 10} 张重复订单`,
        ),
      );
    }
    AntModal.confirm({
      title: `检测到 ${duplicates.length} 张重复采购订单`,
      width: 560,
      zIndex: 1100,
      content: h('div', [
        h(
          'p',
          { class: 'mb-2' },
          '以下订单在系统中已存在,本次未导入。覆盖会用文件内容更新系统内已有订单;已入库、已退货、已关联发票或采购申请、已提交审批流程的订单不会覆盖。如确认是正常的新采购(不是重复上传),请在文件中填写一个系统中不存在的采购单编号后重新导入。',
        ),
        ...lines,
      ]),
      okText: '覆盖重复订单',
      cancelText: '不覆盖,仅保留非重复项',
      maskClosable: false,
      onOk: () => resolve(true),
      onCancel: () => resolve(false),
    });
  });
}
/** 导入结果中的“覆盖重复订单”按钮 */
async function handleOverwrite() {
  if (importing.value || fileList.value.length === 0) return;
  modalApi.lock();
  importing.value = true;
  startMockProgress();
  try {
    importResult.value = await runImport(true);
    stopMockProgress();
    progressPercent.value = 100;
    emit('success');
    const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
    if (failedCount > 0) {
      message.warning(
        `覆盖完成:更新 ${updateCount.value} 张,失败 ${failedCount} 张,请查看下方失败明细`,
      );
    } else {
      message.success(`覆盖完成:更新 ${updateCount.value} 张采购订单`);
    }
  } finally {
    stopMockProgress();
    importing.value = false;
    modalApi.unlock();
  }
}
/** 模拟进度条:请求期间从 0 走到 90% */
function startMockProgress() {
@@ -110,6 +210,9 @@
const failureCount = computed(
  () => Object.keys(importResult.value?.failureList ?? {}).length,
);
const duplicateCount = computed(
  () => importResult.value?.duplicateList?.length ?? 0,
);
</script>
<template>
@@ -130,7 +233,8 @@
      </div>
      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx 格式文件;模板中“供应商名称”按名称(或编码)填写,需与系统内供应商一致;“产品编码”按物料编码填写(“产品名称”列仅供对照,匹配以产品编码为准);“采购时间”格式如 2026-01-01 10:00:00,留空取当前时间。</p>
        <p>一张采购单可含多条明细:相同“采购单编号”的多行会合并为一张订单(主表字段取该组首行);“采购单编号”留空时每一行各生成一张新订单。采购单编号已存在时会导入失败。</p>
        <p>一张采购单可含多条明细:相同“采购单编号”的多行会合并为一张订单(主表字段取该组首行);“采购单编号”留空时每一行各生成一张新订单。</p>
        <p>导入时会自动检测重复订单(采购单编号已存在,或供应商、明细、数量、单价、金额与已有订单完全一致)。检测到重复会弹框提醒,由您决定是否覆盖;已入库、已退货、已关联发票或采购申请的订单不会被覆盖。</p>
        <p>订单状态:勾选上方开关 = 直接置为“已审核”;不勾选 = “未审核”。导入不会自动提交审批流程。</p>
      </div>
      <!-- 导入进度条 -->
@@ -147,8 +251,29 @@
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">新增:{{ createCount }} 张</span>
          <span class="text-blue-600">覆盖更新:{{ updateCount }} 张</span>
          <span class="text-amber-600">重复未导入:{{ duplicateCount }} 张</span>
          <span class="text-red-600">失败:{{ failureCount }} 张</span>
        </div>
        <div v-if="duplicateCount > 0" class="mb-2">
          <div class="max-h-40 overflow-y-auto">
            <div
              v-for="item in importResult.duplicateList"
              :key="`${item.key}-${item.existingId}`"
              class="text-sm leading-6 text-amber-600"
            >
              {{ item.key }} → 已存在订单 {{ item.existingNo }}({{ item.reason }})
            </div>
          </div>
          <Button
            class="mt-2"
            type="primary"
            danger
            :loading="importing"
            @click="handleOverwrite"
          >
            覆盖重复订单
          </Button>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, name) in importResult.failureList"
src/views/erp/purchase/request/index.vue
@@ -95,22 +95,48 @@
/** 提交审批 - 先获取流程列表 */
async function handleSubmit(row: ErpPurchaseRequestApi.PurchaseRequest) {
  await openProcessModal([row.id!]);
}
/** 打开审批流程选择弹窗(支持单张/批量) */
async function openProcessModal(ids: number[]) {
  if (ids.length === 0) return;
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getApproveProcessList(row.id!);
    const processList = await getApproveProcessList(ids[0]!);
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先配置 BPM 流程');
      return;
    }
    // 打开流程选择弹窗
    processModalApi.setData({ id: row.id, processList }).open();
    processModalApi.setData({ ids, processList }).open();
  } catch {
    hideLoading();
  }
}
/** 批量提交:只提交可提交(未提交/审批不通过)的申请 */
function handleBatchSubmit() {
  const eligible = checkedRows.value.filter(
    (row) => row.status === 0 || Number(row.status) === 30,
  );
  if (eligible.length === 0) {
    message.warning(
      '所选采购申请中没有可提交的单据,只有“未提交”或“审批不通过”的申请可以提交',
    );
    return;
  }
  const skipped = checkedRows.value.length - eligible.length;
  if (skipped > 0) {
    message.info(
      `已自动跳过 ${skipped} 张不可提交的申请,本次提交 ${eligible.length} 张`,
    );
  }
  openProcessModal(eligible.map((row) => row.id!));
}
/** 生成采购订单 */
@@ -119,11 +145,13 @@
}
const checkedIds = ref<number[]>([]);
const checkedRows = ref<ErpPurchaseRequestApi.PurchaseRequest[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: ErpPurchaseRequestApi.PurchaseRequest[];
}) {
  checkedRows.value = records;
  checkedIds.value = records.map((item) => item.id!);
}
@@ -220,6 +248,14 @@
              onClick: handleImport,
            },
            {
              label: '批量提交',
              type: 'primary',
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.SUBMIT,
              auth: ['erp:purchase-request:submit'],
              onClick: handleBatchSubmit,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
src/views/erp/purchase/request/modules/process-select-modal.vue
@@ -1,13 +1,16 @@
<script lang="ts" setup>
import type { ErpPurchaseRequestApi } from '#/api/erp/purchase/request';
import { ref, computed } from 'vue';
import { ref, computed, h } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { message, Modal as AntModal, Select } from 'ant-design-vue';
import { submitPurchaseRequest } from '#/api/erp/purchase/request';
import {
  submitPurchaseRequest,
  submitPurchaseRequestBatch,
} from '#/api/erp/purchase/request';
interface ProcessDefinition {
  id: string;
@@ -18,7 +21,7 @@
const emit = defineEmits(['success']);
const requestId = ref<number>();
const requestIds = ref<number[]>([]);
const processList = ref<ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
@@ -29,42 +32,116 @@
  }));
});
const isBatch = computed(() => requestIds.value.length > 1);
const modalTitle = computed(() =>
  isBatch.value
    ? `批量提交采购申请(${requestIds.value.length} 张)`
    : '选择审批流程',
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    if (requestIds.value.length === 0) return;
    modalApi.lock();
    try {
      await submitPurchaseRequest(requestId.value!, selectedProcessKey.value);
      if (!isBatch.value) {
        await submitPurchaseRequest(
          requestIds.value[0]!,
          selectedProcessKey.value,
        );
        message.success('提交成功');
      } else {
        // 分批提交,避免一次性提交过多导致请求超时
        const batchSize = 50;
        const total = requestIds.value.length;
        let successCount = 0;
        const failures: [string, string][] = [];
        const loadingKey = 'purchase-request-batch-submit';
        message.open({
          key: loadingKey,
          type: 'loading',
          content: `正在提交采购申请:0/${total}`,
          duration: 0,
        });
        try {
          for (let i = 0; i < total; i += batchSize) {
            const chunk = requestIds.value.slice(i, i + batchSize);
            const result = await submitPurchaseRequestBatch(
              chunk,
              selectedProcessKey.value,
            );
            successCount += result.successIds?.length ?? 0;
            failures.push(...Object.entries(result.failureMap ?? {}));
            message.open({
              key: loadingKey,
              type: 'loading',
              content: `正在提交采购申请:${Math.min(
                i + batchSize,
                total,
              )}/${total}`,
              duration: 0,
            });
          }
        } finally {
          message.destroy(loadingKey);
        }
        if (failures.length > 0) {
          AntModal.warning({
            title: `批量提交完成:成功 ${successCount} 张,失败 ${failures.length} 张`,
            width: 600,
            zIndex: 1100,
            content: h(
              'div',
              { class: 'max-h-80 overflow-y-auto' },
              failures.map(([name, reason]) =>
                h(
                  'div',
                  { class: 'text-sm leading-6' },
                  `${name}:${reason}`,
                ),
              ),
            ),
            okText: '知道了',
          });
        } else {
          message.success(`批量提交成功,共 ${successCount} 张`);
        }
      }
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      requestId.value = undefined;
      requestIds.value = [];
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{ id: number; processList: ProcessDefinition[] }>();
    requestId.value = data.id;
    const data = modalApi.getData<{
      id?: number;
      ids?: number[];
      processList: ProcessDefinition[];
    }>();
    requestIds.value = data.ids ?? (data.id ? [data.id] : []);
    processList.value = data.processList || [];
    // 默认选择第一个流程
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0].key;
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
  <Modal :title="modalTitle" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
src/views/hrm/attendance/exception/data.ts
@@ -8,6 +8,18 @@
/** 表单类型 */
export type FormType = 'create' | 'detail';
/** 考勤异常类型,与后端 HrmAttendanceExceptionTypeEnum 保持一致 */
const EXCEPTION_TYPE_OPTIONS = [
  { label: '补卡', value: 1 },
  { label: '加班', value: 2 },
  { label: '出差', value: 3 },
];
/** 考勤异常类型名称 */
export function formatExceptionType(value?: number) {
  return EXCEPTION_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? '-';
}
/** 新增考勤异常申请的表单 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
@@ -20,28 +32,23 @@
      },
    },
    {
      fieldName: 'date',
      label: '考勤日期',
      fieldName: 'exceptionDate',
      label: '异常日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择考勤日期',
        placeholder: '请选择异常日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'exceptionType',
      fieldName: 'type',
      label: '异常类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择异常类型',
        options: [
          { label: '漏打卡', value: 1 },
          { label: '迟到', value: 2 },
          { label: '早退', value: 3 },
          { label: '其他', value: 4 },
        ],
        options: EXCEPTION_TYPE_OPTIONS,
      },
      rules: 'selectRequired',
    },
@@ -113,8 +120,14 @@
  return [
    { field: 'userName', title: '申请人', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'date', title: '考勤日期', width: 110 },
    { field: 'exceptionTypeName', title: '异常类型', width: 100 },
    { field: 'exceptionDate', title: '异常日期', width: 110 },
    {
      field: 'type',
      title: '异常类型',
      width: 100,
      formatter: ({ cellValue }: { cellValue: number }) =>
        formatExceptionType(cellValue),
    },
    { field: 'reason', title: '异常原因', minWidth: 200 },
    {
      field: 'status',
src/views/hrm/attendance/exception/detail/approve/index.vue
@@ -14,6 +14,8 @@
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import { formatExceptionType } from '../../data';
defineOptions({ name: 'HrmAttendanceExceptionApproveDetail' });
const props = defineProps<{ id: string }>();
@@ -34,10 +36,14 @@
    { label: '所属部门', field: 'deptName', render: textRender },
    {
      label: '异常日期',
      field: 'date',
      field: 'exceptionDate',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '异常类型', field: 'exceptionTypeName', render: textRender },
    {
      label: '异常类型',
      field: 'type',
      render: (val) => formatExceptionType(val as number),
    },
    { label: '异常说明', field: 'reason', render: textRender },
    {
      label: '审批状态',