From 8cc8b4fb94bf7b1dbce9b406d74ecd55b4ce36d9 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 30 Oct 2026 11:19:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ContainerCustomsPanel.test.ts | 29 ++++++- .../components/ContainerCustomsPanel.vue | 31 ++++--- .../overseas-business/PROTOTYPE_MATRIX.md | 2 +- .../components/BusinessOrderFees.test.ts | 13 +++ .../components/OverseasFeeEditor.vue | 7 +- .../ProcurementWorkflowPanel.test.ts | 82 +++++++++++++++---- .../components/ProcurementWorkflowPanel.vue | 47 +++++------ .../overseas-business/contract.test.ts | 8 +- .../pages/OverseasBusinessViews.test.ts | 19 ++++- .../pages/PurchaseOrderDetailView.vue | 9 +- .../pages/PurchaseOrdersView.vue | 43 ++++++++-- .../overseas-business/procurementTypes.ts | 1 + src/modules/overseas-business/types.ts | 1 + 13 files changed, 218 insertions(+), 74 deletions(-) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 4ec3d45e..cba2c467 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -212,7 +212,7 @@ describe('ContainerCustomsPanel', () => { wrapper = await render('DECLARING', true, true) expect(wrapper.text()).toContain('发送报关行执行') - expect(wrapper.text()).toContain('覆盖预录单') + expect(wrapper.text()).toContain('选择预录单') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一') @@ -262,12 +262,35 @@ describe('ContainerCustomsPanel', () => { const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void onChange({ raw: selected }) await flushPromises() - await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click') + await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click') await flushPromises() expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7) wrapper.unmount() }) + it('一次选择的预录单会关联柜内全部客户资料', async () => { + const value = customs('DECLARING', false) + value.documents.push({ ...value.documents[0]!, documentId: '2092438843630391414', source: { ...value.documents[0]!.source, fileId: '2092438843630391415', fileName: '另一份客户资料.pdf', version: 1 }, receipt: null, confirmationHistory: [] }) + value.brokerSubmission = { id: '2092438843630391412', customsContainerId: value.aggregateId, loadingPlanId: value.loadingPlanId, batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } + api.get.mockResolvedValue(value) + const wrapper = mount(ContainerCustomsPanel, { + props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + const selected = new File(['receipt'], '整柜预录单.pdf', { type: 'application/pdf' }) + const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void + onChange({ raw: selected }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click') + await flushPromises() + + expect(api.upload).toHaveBeenCalledTimes(2) + expect(api.upload).toHaveBeenNthCalledWith(1, value.documents[0]!.documentId, selected, 0) + expect(api.upload).toHaveBeenNthCalledWith(2, value.documents[1]!.documentId, selected, 0) + wrapper.unmount() + }) + it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => { const wrapper = await render('COMPLETED', true, true) expect(wrapper.text()).toContain('客户退税资料.pdf') @@ -341,7 +364,7 @@ describe('ContainerCustomsPanel', () => { const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void onChange({ raw: selected }) await flushPromises() - const submit = wrapper.findAll('button').find(button => button.text() === '提交')! + const submit = wrapper.findAll('button').find(button => button.text() === '提交预录单')! await submit.trigger('click') await flushPromises() diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index c4cc64e6..815f9a3b 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -48,7 +48,7 @@ const actionLoading = ref(false) const loadError = ref(null) const actionError = ref(null) const downloadingKey = ref('') -const receiptFiles = reactive>({}) +const receiptFile = ref(null) const finalFiles = reactive>({}) const clearanceDialogVisible = ref(false) const clearanceBatch = ref(null) @@ -84,9 +84,9 @@ function formatDateTime(value: string | null | undefined) { const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) } -function selectReceipt(documentId: string, upload: UploadFile) { +function selectReceipt(upload: UploadFile) { if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return - receiptFiles[documentId] = upload.raw || null + receiptFile.value = upload.raw || null actionError.value = null } function selectFinal(documentId: string, upload: UploadFile) { @@ -185,18 +185,26 @@ async function start() { finally { actionLoading.value = false } } -async function uploadReceipt(document: ContainerCustomsDocument) { +async function uploadReceipt() { if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return - const selected = receiptFiles[document.documentId] + const selected = receiptFile.value if (!selected) { ElMessage.warning('请选择对应的预录单'); return } + const documents = customs.value.documents + let uploadedCount = 0 actionLoading.value = true; actionError.value = null try { - await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0) - receiptFiles[document.documentId] = null + for (const document of documents) { + await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0) + uploadedCount += 1 + } + receiptFile.value = null await load() emit('refreshed') - ElMessage.success('预录单已上传,等待客户确认') - } catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') } + ElMessage.success(`预录单已上传并关联 ${documents.length} 份客户资料`) + } catch (error) { + if (uploadedCount) await load() + actionError.value = getApiErrorDetails(error, uploadedCount ? `预录单已关联 ${uploadedCount}/${documents.length} 份资料,后续上传失败` : '预录单上传失败') + } finally { actionLoading.value = false } } @@ -317,10 +325,10 @@ onMounted(load)
客户订单号入仓码
{{ item.customerName }}{{ item.orderNo }}{{ item.inboundCode }}
-

已冻结退税资料、预录单与完税文件

+

已冻结退税资料、预录单与完税文件

{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}{{ receiptFile.name }}提交预录单
- + @@ -361,4 +369,5 @@ onMounted(load) diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md index 36509701..7d937d87 100644 --- a/src/modules/overseas-business/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md @@ -24,7 +24,7 @@ | 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 | | 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` | | 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 | -| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 详情页顶部操作 + 居中确认弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;提交版本、通过/拒绝决定、稳定幂等键及可选备注 | +| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 列表待审核行操作 + 审核弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;通过需选择采购仓,驳回原因按 `remark` 提交;保留幂等键 | | 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 | | 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 | | 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 | diff --git a/src/modules/overseas-business/components/BusinessOrderFees.test.ts b/src/modules/overseas-business/components/BusinessOrderFees.test.ts index f5e74aea..27303cb8 100644 --- a/src/modules/overseas-business/components/BusinessOrderFees.test.ts +++ b/src/modules/overseas-business/components/BusinessOrderFees.test.ts @@ -68,6 +68,19 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('单据费用操作', () => { + it('resets a previous bill selection to wallet when switching to procurement', async () => { + const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, items: [] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await wrapper.setProps({ modelValue: true }); await flushPromises() + const dialog = activeDialog(wrapper) + const form = dialog.getComponent({ name: 'ElForm' }).props('model') + form.settlementMode = 'BILL' + const typeSelect = dialog.findAllComponents({ name: 'ElSelect' })[0]! + typeSelect.vm.$emit('update:modelValue', 'OVERSEAS_PROCUREMENT') + typeSelect.vm.$emit('change', 'OVERSEAS_PROCUREMENT') + await flushPromises() + expect(form.settlementMode).toBe('WALLET') + expect(api.createOverseasFee).not.toHaveBeenCalled() + }) it('uses the same fee dialog for procurement with balance deduction selected by default', async () => { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' } diff --git a/src/modules/overseas-business/components/OverseasFeeEditor.vue b/src/modules/overseas-business/components/OverseasFeeEditor.vue index 92879c42..b65c8c1a 100644 --- a/src/modules/overseas-business/components/OverseasFeeEditor.vue +++ b/src/modules/overseas-business/components/OverseasFeeEditor.vue @@ -48,7 +48,12 @@ async function loadOptions(keyword = '') { } catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') } finally { if (sequence === optionSequence) optionsLoading.value = false } } -function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() } +function changeBusinessType() { + form.businessId = ''; options.value = [] + // 切换业务时重置结算选择,采购不能沿用上一业务的账期选择。 + form.settlementMode = form.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : '' + void loadOptions() +} const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId)) const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode)) let existingSequence = 0 diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts index d78312c2..dc3ae684 100644 --- a/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' -import ElementPlus from 'element-plus' +import ElementPlus, { ElInputNumber } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { useAuthStore } from '@/core/auth/auth' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -9,14 +9,12 @@ import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue' import type { PlatformProcurement } from '../procurementTypes' import type { ProcurementWorkflow } from '../api/procurementWorkflow' import { submitProcurementWorkflow } from '../api/procurementWorkflow' -import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' -import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' +import { uploadProcurementFile } from '../api/procurementCommands' vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() })) -vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() })) -vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() })) +vi.mock('../api/procurementCommands', () => ({ uploadProcurementFile: vi.fn() })) vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) enableAutoUnmount(afterEach) -const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] }) +const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] }) const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement const beforeOutbound = vi.fn<() => Promise>() function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) { @@ -24,8 +22,70 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin() return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } }) } -beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) }) +beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) }) describe('simplified procurement operations', () => { + it('allows a rejected purchase to be resubmitted without uploading photos and clears the old reason after success', async () => { + const wrapper = render() + const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [ + { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, + { id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' }, + ] } + await wrapper.setProps({ workflow: rejected }); await flushPromises() + const button = wrapper.findAll('button').find(button => button.text() === '采购完成')! + expect(button.attributes('disabled')).toBeUndefined() + expect(wrapper.text()).toContain('请核对') + await button.trigger('click'); await flushPromises() + wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + expect(uploadProcurementFile).not.toHaveBeenCalled() + expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) }) + expect(wrapper.emitted('changed')).toHaveLength(1) + await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5, rejectionReason: null, photosNeedUpdate: false } }) + expect(wrapper.text()).not.toContain('客户驳回原因') + }) + it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => { + const wrapper = render() + const receive = () => wrapper.findAll('button').find(button => button.text() === '登记收货')! + await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: 0, stockQuantity: 0, availableQuantity: 0 } }) + await receive().trigger('click'); await flushPromises() + const quantity = wrapper.findComponent(ElInputNumber) + const increment = quantity.find('.el-input-number__increase') + expect(increment.attributes('aria-disabled')).not.toBe('true') + await increment.trigger('mousedown', { button: 0 }) + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + expect(quantity.props('modelValue')).toBe(2) + await quantity.find('input').setValue('10') + await quantity.find('input').trigger('blur') + expect(quantity.props('modelValue')).toBe(10) + const form = wrapper.findComponent({ name: 'ElForm' }).props('model') + form.fileIds = ['photo-1'] + const dialog = wrapper.findComponent(AppDialog) + dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 4, quantity: 10, fileIds: ['photo-1'] })) + expect(wrapper.emitted('changed')).toHaveLength(1) + + await wrapper.setProps({ workflow: { ...workflow(), version: 5, receivedQuantity: 10, stockQuantity: 10, availableQuantity: 10 } }) + await receive().trigger('click'); await flushPromises() + await wrapper.findComponent(ElInputNumber).find('input').setValue('10') + await wrapper.findComponent(ElInputNumber).find('input').trigger('blur') + form.fileIds = ['photo-2'] + dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 5, quantity: 10, fileIds: ['photo-2'] })) + expect(wrapper.emitted('changed')).toHaveLength(2) + const calls = vi.mocked(submitProcurementWorkflow).mock.calls + expect(calls[0]![3].idempotencyKey).not.toBe(calls[1]![3].idempotencyKey) + }) + it.each([ + { received: 1, input: '10', expected: 10 }, + { received: 18, input: '3', expected: 2 }, + ])('limits receipts by remaining purchase quantity after $received units were received', async ({ received, input, expected }) => { + const wrapper = render() + await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: received, stockQuantity: received, availableQuantity: received } }) + await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click'); await flushPromises() + const quantity = wrapper.findComponent(ElInputNumber) + await quantity.find('input').setValue(input) + await quantity.find('input').trigger('blur') + expect(quantity.props('modelValue')).toBe(expected) + }) it('allows outbound when the fee check reports missing or unreviewed details', async () => { const wrapper = render('CONFIRMED') const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')! @@ -68,14 +128,6 @@ describe('simplified procurement operations', () => { expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3) form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) }) - it('requires a rejection reason and preserves the current version and customer scope', async () => { - const wrapper = render('PENDING_REVIEW', ['review']) - await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises() - const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog) - form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled() - form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises() - expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined })) - }) it('only completes the selected delivering batch after signature evidence is supplied', async () => { const wrapper = render('DELIVERING') await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } }) diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue index 3c3c6de9..afdfc17b 100644 --- a/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue @@ -7,22 +7,24 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' -import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' -import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' +import { uploadProcurementFile } from '../api/procurementCommands' import { editProcurementOutbound, submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow' import { dateTime, statusLabel } from '../procurementPresentation' import type { PlatformProcurement } from '../procurementTypes' -import type { OverseasPurchaseWarehouseOption } from '../types' const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise }>() const emit = defineEmits<{ changed: []; download: [id: string] }>() const access = useAccessPreviewStore() const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref(null) -const action = ref(''), batchId = ref(''), warehouses = ref([]), warehouseLoading = ref(false) -const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' }) +const action = ref(''), batchId = ref('') +const remainingReceiptQuantity = computed(() => props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) +const quantityLimit = computed(() => Math.max(1, action.value === 'receipts' + ? remainingReceiptQuantity.value + : props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0))) +const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[] }) const uploadedNames = ref([]) -const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作') -const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`) +const title = computed(() => ({ receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作') +const permission = computed(() => `platform:overseas-business:purchase:${['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`) const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`) const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas') ? '当前账号未开通采购收货权限,请联系管理员开通。' @@ -35,7 +37,7 @@ watch(scope, () => { dialogGeneration++; open.value = false; error.value = null watch(open, value => { if (!value) dialogGeneration++ }) async function begin(name: string, batch?: ProcurementOutbound | ProcurementWorkflowReceipt) { if (saving.value || uploading.value || checkingFees.value) return - const required = name === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer' + const required = ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer' if (!can(required)) return const current = ++dialogGeneration if (name === 'outbounds') { @@ -47,16 +49,10 @@ async function begin(name: string, batch?: ProcurementOutbound | ProcurementWork if (current !== dialogGeneration) return if (noFeeDetails) ElMessage.info('没有新增费用明细,仍可继续创建出库单') } - action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false - Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = [] + action.value = name; batchId.value = batch?.id || ''; error.value = null + Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [] }); uploadedNames.value = [] if (name === 'edit-outbound' && batch && 'trackingNo' in batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo }) fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline() - if (name === 'review') { - warehouseLoading.value = true - try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result } - catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') } - finally { if (current === dialogGeneration) warehouseLoading.value = false } - } } async function upload(event: Event) { const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = '' @@ -74,20 +70,17 @@ async function upload(event: Event) { } async function save() { if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return - if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return } - if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return } if (['receipts', 'outbounds', 'edit-outbound'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return } - if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return } + if (action.value === 'receipts' && form.quantity > remainingReceiptQuantity.value) { ElMessage.warning('收货数量不能超过剩余采购数量'); return } if (['outbounds', 'edit-outbound'].includes(action.value) && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0))) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return } if (['receipts', 'receipt-photos', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return } const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : ['outbounds', 'edit-outbound'].includes(action.value) ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'receipt-photos' ? { receiptId: batchId.value, fileIds: [...form.fileIds] } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) } - const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data]) + const next = JSON.stringify([action.value, batchId.value, data]) if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() } const current = dialogGeneration saving.value = true; error.value = null try { - if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined }) - else if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey }) + if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey }) else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value && action.value !== 'receipt-photos' ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey }) if (current !== dialogGeneration) return captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed') @@ -110,18 +103,16 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
- 审核 - + 创建出库单
{{ permissionHint }}
累计收货
{{ workflow.receivedQuantity }}
采购库存
{{ workflow.stockQuantity }}
出库处理中
{{ workflow.reservedQuantity }}
可出库
{{ workflow.availableQuantity }}
-

收货记录

+

收货记录

出库单

- + - - +
{{ name }}

确认采购完成并提交客户确认?

确认本批次开始派送?

确认取消本批出库单?取消后库存占用将释放。

diff --git a/src/modules/overseas-business/contract.test.ts b/src/modules/overseas-business/contract.test.ts index ff61663b..c6965493 100644 --- a/src/modules/overseas-business/contract.test.ts +++ b/src/modules/overseas-business/contract.test.ts @@ -14,12 +14,16 @@ describe('overseas business real contract pages', () => { expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/) }) - it('keeps procurement general editing limited to internal remarks', () => { + it('keeps procurement editing limited to remarks and reviews pending rows from the list', () => { const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue') expect(list).toContain('listOverseasPurchaseCustomerOptions') expect(edit).toContain('getOverseasPurchaseEditor') - expect(detail).toContain('detail.reviewHistory') + expect(detail).toContain('编辑') + expect(detail).not.toContain('编辑备注') expect(edit).toContain('remark: form.remark.trim() || null') expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/) expect(edit).not.toContain('入库量不能小于 0') diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index 8284a1f7..c7ef27f3 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam' import type { PlatformProcurement } from '../procurementTypes' import { downloadProcurementFile } from '../api/procurementCommands' +import { submitProcurementCommand } from '../api/procurementCommands' import { getProcurementWorkflow } from '../api/procurementWorkflow' import type { FleetArrangement } from '../fleetArrangement' import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types' @@ -34,7 +35,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() })) vi.mock('../api/procurementCommands', async importOriginal => ({ ...await importOriginal(), - downloadProcurementFile: vi.fn(), + downloadProcurementFile: vi.fn(), submitProcurementCommand: vi.fn(), })) vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(), @@ -72,7 +73,7 @@ describe('overseas business mounted pages', () => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = '' routing.push.mockReset(); routing.route.params = {}; routing.route.query = {} vi.mocked(downloadProcurementFile).mockReset() - vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] }) + vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] }) vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }) vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark }) vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }]) @@ -172,6 +173,8 @@ describe('overseas business mounted pages', () => { expect(wrapper.text()).toContain('收货记录') expect(wrapper.text()).toContain('出库单') expect(wrapper.text()).toContain('采购库存') + expect(wrapper.text()).toContain('采购要求') + expect(wrapper.text()).toContain('采购备注') expect(wrapper.text()).not.toContain('两段实际交寄') expect(wrapper.text()).not.toContain('internal-purchase-route') await wrapper.get('.density-back').trigger('click') @@ -183,7 +186,7 @@ describe('overseas business mounted pages', () => { const result = procurementDetail() result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }] vi.mocked(getOverseasPurchase).mockResolvedValue(result) - vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] }) + vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] }) let rejectDownload!: (error: Error) => void vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject })) const wrapper = mountPage(PurchaseOrderDetailView) @@ -209,6 +212,16 @@ describe('overseas business mounted pages', () => { }) it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') }) + it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => { + vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 }) + const wrapper = mountPage(PurchaseOrdersView); await flushPromises() + expect(wrapper.text()).not.toContain('编辑') + await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises() + const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model') as { decision: string; remark: string } + form.decision = 'REJECT'; form.remark = '商品型号与要求不符'; dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ version: 3, decision: 'REJECT', remark: '商品型号与要求不符' })) + expect(submitProcurementCommand).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ reason: expect.anything() })) + }) it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => { const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' } vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 }) diff --git a/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue b/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue index ad70ce24..f73e3f87 100644 --- a/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue +++ b/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue @@ -5,7 +5,6 @@ import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowLeft } from '@lucide/vue' import { useAuthStore } from '@/core/auth/auth' -import { useAccessPreviewStore } from '@/core/access/accessPreview' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' @@ -18,12 +17,11 @@ import { downloadProcurementFile } from '../api/procurementCommands' import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow' import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue' import BusinessDocumentFees from '../components/BusinessDocumentFees.vue' -import { overseasBusinessPermissions } from '../permissions' import { statusLabel, money, dateTime, text } from '../procurementPresentation' import type { PlatformProcurement } from '../procurementTypes' import { directionLabel } from '../procurementDirection' import { hasProcurementFeeDetails } from '../procurementOutboundFees' -const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore() +const route = useRoute(); const router = useRouter(); const auth = useAuthStore() const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '') const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '') const detail = ref(null), workflow = ref(null), loading = ref(false) @@ -40,7 +38,8 @@ const fields = computed(() => [ ['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)], ['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress], - ...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []), + ...(order.value?.remark ? [['采购要求', order.value.remark]] : []), + ...(detail.value?.internalRemark || order.value?.remark ? [['采购备注', detail.value?.internalRemark || order.value?.remark]] : []), ]) let generation = 0, inFlight: Promise | null = null, inFlightScope = '' const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`) @@ -78,7 +77,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
操作关联入仓码报关行状态客户退税资料预录单真实报关完税文件客户预录单情况
{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交
只读
{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交
只读
{{ document.inboundCodes.join('、') || '—' }} {{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}
{{ document.source.fileName }}下载