9 天以前 c5b6bf8931e80e5b097af0f93c95ee82164cf872
feat(srm): 添加供应商证书和招标管理附件功能

- 移除回款状态和审核状态相关字段
- 为供应商证书模块添加证书附件上传功能
- 为招标管理模块添加资质要求附件上传功能
- 在详情页面展示附件列表并支持预览
- 修改采购发票查询逻辑以适配新附件功能
- 更新供应商列表API获取方式
- 优化模型页面初始化逻辑
- 在统计数据页面添加节约金额说明提示
- 实现附件列表的表格展示和文件预览功能
已修改17个文件
239 ■■■■ 文件已修改
src/api/erp/purchase/invoice/index.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/supplier/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/supplierCertificate/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/tender/index.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/form-create/helpers.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/index.vue 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/components/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/data.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/statistics/index.vue 9 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/data.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplierCertificate/modules/form.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/data.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/modules/form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/index.ts
@@ -26,8 +26,6 @@
    invoiceTitle?: string;
    price?: number;
    invoiceTime?: string;
    auditStatus?: number;
    processInstanceId?: string;
    remark?: string;
    attachmentList?: { id: number; name?: string; url?: string }[];
    blobIds?: number[];
@@ -48,7 +46,6 @@
    price?: number;
    supplierId?: number;
    purchaseOrderId?: number;
    auditStatus?: number;
    hasAttachment?: boolean;
    remainingInvoicePrice?: number;
  }
@@ -89,19 +86,7 @@
  return requestClient.download('/erp/purchase-invoice/export-excel', { params });
}
/** 更新来票的状态(审核/反审核) */
export function updatePurchaseInvoiceStatus(id: number, status: number) {
  return requestClient.put('/erp/purchase-invoice/update-status', null, {
    params: { id, status },
  });
}
/** 获得待审核来票数量 */
export function getAuditPurchaseInvoiceCount() {
  return requestClient.get<number>('/erp/purchase-invoice/audit-count');
}
/** 查询来票下拉列表(按供应商可选来票,仅审核通过且已上传发票附件) */
/** 查询来票下拉列表(按供应商可选来票,已上传发票附件且剩余可付金额大于 0) */
export function getPurchaseInvoiceSimpleList(
  supplierId: number,
  purchaseOrderId?: number,
src/api/srm/supplier/index.ts
@@ -31,6 +31,11 @@
  return requestClient.get<SrmSupplierApi.SupplierVO[]>('/srm/supplier/simple-list');
}
/** 获取供应商档案列表(不过滤准入状态) */
export function getSupplierList() {
  return requestClient.get<SrmSupplierApi.SupplierVO[]>('/srm/supplier/list');
}
/** 获取供应商详情 */
export function getSupplier(id: number) {
  return requestClient.get<SrmSupplierApi.SupplierVO>(`/srm/supplier/get?id=${id}`);
src/api/srm/supplierCertificate/index.ts
@@ -1,5 +1,6 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace SrmSupplierCertificateApi {
@@ -16,6 +17,8 @@
    status?: number;
    remark?: string;
    createTime?: string;
    blobIds?: number[];
    attachmentList?: SystemStorageApi.StorageBlob[];
  }
}
src/api/srm/tender/index.ts
@@ -1,5 +1,6 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace SrmTenderApi {
@@ -23,6 +24,8 @@
    purchaseOrderNo?: string;
    biddingMode?: string;
    createTime?: string;
    blobIds?: number[];
    attachmentList?: SystemStorageApi.StorageBlob[];
  }
  export interface TenderMaterialVO {
@@ -105,6 +108,7 @@
    id: number;
    tenderDesc?: string;
    qualificationRequirements?: string;
    blobIds?: number[];
  }
}
src/components/form-create/helpers.ts
@@ -28,7 +28,10 @@
}
/** 解码表单 Conf */
export function decodeConf(conf: string) {
export function decodeConf(conf?: string | null) {
  if (!conf) {
    return {};
  }
  return formCreate.parseJson(conf);
}
@@ -43,9 +46,9 @@
}
/** 解码表单 Fields */
export function decodeFields(fields: string[]) {
export function decodeFields(fields?: string[] | null) {
  const rule: Rule[] = [];
  fields.forEach((item) => {
  (fields || []).forEach((item) => {
    rule.push(formCreate.parseJson(item));
  });
  return rule;
src/views/bpm/model/index.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ModelCategoryInfo } from '#/api/bpm/model';
import { onActivated, reactive, ref, useTemplateRef, watch } from 'vue';
import { onActivated, onMounted, reactive, ref, useTemplateRef, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
@@ -76,6 +76,11 @@
}
/** 初始化 */
onMounted(() => {
  getList();
});
/** 激活时刷新(keep-alive 缓存场景下从编辑页返回时触发) */
onActivated(() => {
  getList();
});
src/views/crm/receivable/components/data.ts
@@ -53,16 +53,6 @@
      minWidth: 150,
    },
    {
      title: '回款状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
    },
    {
      title: '操作',
      field: 'actions',
      width: 130,
src/views/crm/receivable/data.ts
@@ -357,16 +357,6 @@
      minWidth: 150,
    },
    {
      title: '回款状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
    },
    {
      title: '操作',
      field: 'actions',
      minWidth: 200,
src/views/erp/purchase/invoice/data.ts
@@ -148,19 +148,6 @@
        valueField: 'id',
      },
    },
    {
      fieldName: 'auditStatus',
      label: '审核状态',
      component: 'Select',
      componentProps: {
        options: [
          { label: '未审核', value: 10 },
          { label: '已审核', value: 20 },
        ],
        placeholder: '请选择审核状态',
        allowClear: true,
      },
    },
  ];
}
@@ -221,16 +208,9 @@
      slots: { default: 'attachment' },
    },
    {
      title: '审批状态',
      field: 'auditStatus',
      minWidth: 100,
      fixed: 'right',
      slots: { default: 'auditStatus' },
    },
    {
      title: '操作',
      field: 'actions',
      minWidth: 280,
      minWidth: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/erp/purchase/invoice/index.vue
@@ -12,7 +12,6 @@
  deletePurchaseInvoice,
  exportPurchaseInvoice,
  getPurchaseInvoicePage,
  updatePurchaseInvoiceStatus,
} from '#/api/erp/purchase/invoice';
import { $t } from '#/locales';
@@ -62,24 +61,6 @@
  try {
    await deletePurchaseInvoice(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** 更新来票状态(审核/反审核) */
async function handleUpdateStatus(
  row: ErpPurchaseInvoiceApi.PurchaseInvoice,
  status: number,
) {
  const hideLoading = message.loading({
    content: '更新状态中...',
    duration: 0,
  });
  try {
    await updatePurchaseInvoiceStatus(row.id!, status);
    message.success('更新状态成功');
    handleRefresh();
  } finally {
    hideLoading();
@@ -153,11 +134,6 @@
        <Tag v-if="!row.hasAttachment" color="warning">未上传</Tag>
        <Tag v-else color="success">已上传</Tag>
      </template>
      <template #auditStatus="{ row }">
        <Tag v-if="row.auditStatus === 10" color="warning">未审核</Tag>
        <Tag v-else-if="row.auditStatus === 20" color="success">已审核</Tag>
        <Tag v-else color="default">--</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
@@ -173,24 +149,7 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:purchase-invoice:update'],
              ifShow: row.auditStatus === 10,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '审核',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['erp:purchase-invoice:update-status'],
              ifShow: row.auditStatus === 10,
              onClick: handleUpdateStatus.bind(null, row, 20),
            },
            {
              label: '反审核',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['erp:purchase-invoice:update-status'],
              ifShow: row.auditStatus === 20,
              onClick: handleUpdateStatus.bind(null, row, 10),
            },
            {
              label: $t('common.delete'),
@@ -198,7 +157,6 @@
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['erp:purchase-invoice:delete'],
              ifShow: row.auditStatus !== 20,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
src/views/srm/statistics/index.vue
@@ -96,7 +96,14 @@
        </div>
        <div class="rounded-lg bg-purple-50 p-6 text-center shadow-sm">
          <div class="text-3xl font-bold text-purple-600">{{ stats.savedAmount?.toLocaleString() ?? 0 }}</div>
          <div class="mt-2 text-gray-500">节约金额(元)</div>
          <div class="mt-2 flex items-center justify-center gap-1 text-gray-500">
            节约金额(元)
            <a-tooltip title="节约金额 = 招标总金额(预算金额) − 定标总金额(中标金额)">
              <span
                class="inline-flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-gray-400 text-[10px] leading-none text-white"
              >?</span>
            </a-tooltip>
          </div>
        </div>
        <div class="rounded-lg bg-teal-50 p-6 text-center shadow-sm">
          <div class="text-3xl font-bold text-teal-600">{{ stats.winRate ?? 0 }}%</div>
src/views/srm/supplierCertificate/data.ts
@@ -83,6 +83,19 @@
      rules: 'required',
    },
    {
      fieldName: 'blobIds',
      label: '证书附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        accept: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
        helpText: '支持jpg、jpeg、png、pdf、doc、docx格式,单个文件不超过2MB',
      },
      formItemClass: 'md:col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
@@ -144,6 +157,13 @@
    { field: 'validEndDate', title: '到期日期', minWidth: 120 },
    { field: 'createTime', title: '创建时间', minWidth: 160 },
    {
      field: 'attachmentList',
      title: '附件',
      minWidth: 100,
      fixed: 'right',
      slots: { default: 'attachment' },
    },
    {
      title: '操作',
      width: 130,
      fixed: 'right',
src/views/srm/supplierCertificate/index.vue
@@ -4,7 +4,7 @@
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -112,6 +112,21 @@
          ]"
        />
      </template>
      <template #attachment="{ row }">
        <div v-if="row.attachmentList && row.attachmentList.length" class="flex flex-col gap-1">
          <a
            v-for="file in row.attachmentList"
            :key="file.id"
            :href="file.url"
            target="_blank"
            rel="noopener noreferrer"
            class="text-blue-500 hover:underline"
          >
            {{ file.name || '附件-' + file.id }}
          </a>
        </div>
        <Tag v-else color="default">--</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
src/views/srm/supplierCertificate/modules/form.vue
@@ -65,6 +65,17 @@
    modalApi.lock();
    try {
      formData.value = await getCertificate(data.id);
      if (formData.value?.attachmentList?.length) {
        // 转为上传组件的文件对象;提交时 formApi 会按 valueKey 提取附件 ID
        (formData.value as Record<string, unknown>).blobIds =
          formData.value.attachmentList.map((item) => ({
            uid: String(item.id),
            name: item.name || '',
            url: item.url || '',
            status: 'done',
            id: item.id,
          }));
      }
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
src/views/srm/tender/data.ts
@@ -180,6 +180,19 @@
      componentProps: { placeholder: '请输入资质要求', rows: 3 },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '资质要求附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        accept: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
        helpText: '支持jpg、jpeg、png、pdf、doc、docx格式,单个文件不超过2MB',
      },
      formItemClass: 'col-span-2',
    },
  ];
}
src/views/srm/tender/detail.vue
@@ -19,7 +19,7 @@
  createAward, deleteAward, getAward, getAwardByProject, approveAward, generatePurchaseOrder,
  confirmTenderProject,
} from '#/api/srm/tender';
import { getSupplierSimpleList } from '#/api/srm/supplier';
import { getSupplierList } from '#/api/srm/supplier';
import { getItemSimpleList } from '#/api/mdm/item';
import type { MdmItemApi } from '#/api/mdm/item';
import {
@@ -51,7 +51,7 @@
onMounted(async () => {
  const [items, suppliers] = await Promise.all([
    getItemSimpleList(),
    getSupplierSimpleList(),
    getSupplierList(),
  ]);
  mdmItemList.value = items;
  supplierList.value = suppliers;
@@ -538,14 +538,32 @@
          </div>
          <!-- 招标说明 / 资质要求 -->
          <template v-if="project.tenderDesc || project.qualificationRequirements">
          <template
            v-if="project.tenderDesc || project.qualificationRequirements || (project.attachmentList && project.attachmentList.length)"
          >
            <a-divider class="!my-2" />
            <a-collapse :bordered="false" ghost expand-icon-position="end">
              <a-collapse-panel v-if="project.tenderDesc" key="desc" header="招标说明">
                <p class="text-gray-500 text-xs whitespace-pre-wrap">{{ project.tenderDesc }}</p>
              </a-collapse-panel>
              <a-collapse-panel v-if="project.qualificationRequirements" key="req" header="资质要求">
                <p class="text-gray-500 text-xs whitespace-pre-wrap">{{ project.qualificationRequirements }}</p>
              <a-collapse-panel
                v-if="project.qualificationRequirements || (project.attachmentList && project.attachmentList.length)"
                key="req"
                header="资质要求"
              >
                <p v-if="project.qualificationRequirements" class="text-gray-500 text-xs whitespace-pre-wrap">{{ project.qualificationRequirements }}</p>
                <div v-if="project.attachmentList && project.attachmentList.length" class="mt-2 flex flex-col gap-1">
                  <a
                    v-for="file in project.attachmentList"
                    :key="file.id"
                    :href="file.url"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="text-blue-500 text-xs hover:underline"
                  >
                    {{ file.name || '附件-' + file.id }}
                  </a>
                </div>
              </a-collapse-panel>
            </a-collapse>
          </template>
src/views/srm/tender/modules/form.vue
@@ -58,6 +58,7 @@
          id: data.id!,
          tenderDesc: data.tenderDesc,
          qualificationRequirements: data.qualificationRequirements,
          blobIds: data.blobIds,
        });
      } else {
        await (formData.value?.id
@@ -92,6 +93,17 @@
    modalApi.lock();
    try {
      formData.value = await getTenderProject(data.id);
      if (formData.value?.attachmentList?.length) {
        // 转为上传组件的文件对象;提交时 formApi 会按 valueKey 提取附件 ID
        (formData.value as Record<string, unknown>).blobIds =
          formData.value.attachmentList.map((item) => ({
            uid: String(item.id),
            name: item.name || '',
            url: item.url || '',
            status: 'done',
            id: item.id,
          }));
      }
      const partial = formData.value.tenderStatus !== undefined
        && formData.value.tenderStatus !== TENDER_STATUS.DRAFT;
      await formApi.setValues({ ...formData.value, _partial: partial || undefined });