feat(srm): 添加供应商证书和招标管理附件功能
- 移除回款状态和审核状态相关字段
- 为供应商证书模块添加证书附件上传功能
- 为招标管理模块添加资质要求附件上传功能
- 在详情页面展示附件列表并支持预览
- 修改采购发票查询逻辑以适配新附件功能
- 更新供应商列表API获取方式
- 优化模型页面初始化逻辑
- 在统计数据页面添加节约金额说明提示
- 实现附件列表的表格展示和文件预览功能
| | |
| | | invoiceTitle?: string; |
| | | price?: number; |
| | | invoiceTime?: string; |
| | | auditStatus?: number; |
| | | processInstanceId?: string; |
| | | remark?: string; |
| | | attachmentList?: { id: number; name?: string; url?: string }[]; |
| | | blobIds?: number[]; |
| | |
| | | price?: number; |
| | | supplierId?: number; |
| | | purchaseOrderId?: number; |
| | | auditStatus?: number; |
| | | hasAttachment?: boolean; |
| | | remainingInvoicePrice?: number; |
| | | } |
| | |
| | | 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, |
| | |
| | | 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}`); |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import type { SystemStorageApi } from '#/api/system/storage'; |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace SrmSupplierCertificateApi { |
| | |
| | | status?: number; |
| | | remark?: string; |
| | | createTime?: string; |
| | | blobIds?: number[]; |
| | | attachmentList?: SystemStorageApi.StorageBlob[]; |
| | | } |
| | | } |
| | | |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import type { SystemStorageApi } from '#/api/system/storage'; |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace SrmTenderApi { |
| | |
| | | purchaseOrderNo?: string; |
| | | biddingMode?: string; |
| | | createTime?: string; |
| | | blobIds?: number[]; |
| | | attachmentList?: SystemStorageApi.StorageBlob[]; |
| | | } |
| | | |
| | | export interface TenderMaterialVO { |
| | |
| | | id: number; |
| | | tenderDesc?: string; |
| | | qualificationRequirements?: string; |
| | | blobIds?: number[]; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** 解码表单 Conf */ |
| | | export function decodeConf(conf: string) { |
| | | export function decodeConf(conf?: string | null) { |
| | | if (!conf) { |
| | | return {}; |
| | | } |
| | | return formCreate.parseJson(conf); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** 解码表单 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; |
| | |
| | | <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'; |
| | |
| | | } |
| | | |
| | | /** 初始化 */ |
| | | onMounted(() => { |
| | | getList(); |
| | | }); |
| | | |
| | | /** 激活时刷新(keep-alive 缓存场景下从编辑页返回时触发) */ |
| | | onActivated(() => { |
| | | getList(); |
| | | }); |
| | |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | title: '回款状态', |
| | | field: 'auditStatus', |
| | | minWidth: 100, |
| | | fixed: 'right', |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.CRM_AUDIT_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | title: '操作', |
| | | field: 'actions', |
| | | width: 130, |
| | |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | title: '回款状态', |
| | | field: 'auditStatus', |
| | | minWidth: 100, |
| | | fixed: 'right', |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.CRM_AUDIT_STATUS }, |
| | | }, |
| | | }, |
| | | { |
| | | title: '操作', |
| | | field: 'actions', |
| | | minWidth: 200, |
| | |
| | | valueField: 'id', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'auditStatus', |
| | | label: '审核状态', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: [ |
| | | { label: '未审核', value: 10 }, |
| | | { label: '已审核', value: 20 }, |
| | | ], |
| | | placeholder: '请选择审核状态', |
| | | allowClear: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | 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' }, |
| | | }, |
| | |
| | | deletePurchaseInvoice, |
| | | exportPurchaseInvoice, |
| | | getPurchaseInvoicePage, |
| | | updatePurchaseInvoiceStatus, |
| | | } from '#/api/erp/purchase/invoice'; |
| | | import { $t } from '#/locales'; |
| | | |
| | |
| | | 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(); |
| | |
| | | <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="[ |
| | |
| | | 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'), |
| | |
| | | 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), |
| | |
| | | </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> |
| | |
| | | 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', |
| | |
| | | { 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', |
| | |
| | | |
| | | 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 { |
| | |
| | | ]" |
| | | /> |
| | | </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="[ |
| | |
| | | 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(); |
| | |
| | | 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', |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | onMounted(async () => { |
| | | const [items, suppliers] = await Promise.all([ |
| | | getItemSimpleList(), |
| | | getSupplierSimpleList(), |
| | | getSupplierList(), |
| | | ]); |
| | | mdmItemList.value = items; |
| | | supplierList.value = suppliers; |
| | |
| | | </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> |
| | |
| | | id: data.id!, |
| | | tenderDesc: data.tenderDesc, |
| | | qualificationRequirements: data.qualificationRequirements, |
| | | blobIds: data.blobIds, |
| | | }); |
| | | } else { |
| | | await (formData.value?.id |
| | |
| | | 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 }); |