From 302cc50a4bf6f619f0eaed2bfb0c6f823087437c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 02:19:39 +0800 Subject: [PATCH] feat(procurement): add sample fulfillment operation workspace --- .../overseas-business/PROTOTYPE_MATRIX.md | 2 + .../api/platformOverseasBusiness.test.ts | 31 ++- .../api/platformOverseasBusiness.ts | 60 ++-- .../api/procurementCommands.test.ts | 58 ++++ .../api/procurementCommands.ts | 62 +++++ .../components/ProcurementChargeFields.vue | 29 ++ .../components/ProcurementControlFields.vue | 41 +++ .../components/ProcurementFinanceFields.vue | 74 +++++ .../components/ProcurementNodeActions.test.ts | 213 ++++++++++++++ .../components/ProcurementNodeActions.vue | 178 ++++++++++++ .../components/ProcurementPhysicalFields.vue | 50 ++++ .../components/ProcurementTransportFields.vue | 61 ++++ .../overseas-business/contract.test.ts | 10 +- .../pages/OverseasBusinessViews.test.ts | 115 +++++--- .../pages/PurchaseOrderDetailView.vue | 263 +++++++++++------- .../pages/PurchaseOrderEditView.vue | 119 ++++---- .../pages/PurchaseOrdersView.vue | 39 ++- .../procurementControlForm.test.ts | 66 +++++ .../procurementControlForm.ts | 51 ++++ .../procurementFinanceForm.test.ts | 119 ++++++++ .../procurementFinanceForm.ts | 113 ++++++++ .../overseas-business/procurementPending.ts | 132 +++++++++ .../procurementPhysicalForm.test.ts | 80 ++++++ .../procurementPhysicalForm.ts | 76 +++++ .../procurementPresentation.ts | 65 +++++ .../procurementTransportForm.test.ts | 71 +++++ .../procurementTransportForm.ts | 75 +++++ .../overseas-business/procurementTypes.ts | 119 ++++++++ src/modules/overseas-business/types.ts | 32 +-- src/styles/adminDensity.test.ts | 4 +- 30 files changed, 2107 insertions(+), 301 deletions(-) create mode 100644 src/modules/overseas-business/api/procurementCommands.test.ts create mode 100644 src/modules/overseas-business/api/procurementCommands.ts create mode 100644 src/modules/overseas-business/components/ProcurementChargeFields.vue create mode 100644 src/modules/overseas-business/components/ProcurementControlFields.vue create mode 100644 src/modules/overseas-business/components/ProcurementFinanceFields.vue create mode 100644 src/modules/overseas-business/components/ProcurementNodeActions.test.ts create mode 100644 src/modules/overseas-business/components/ProcurementNodeActions.vue create mode 100644 src/modules/overseas-business/components/ProcurementPhysicalFields.vue create mode 100644 src/modules/overseas-business/components/ProcurementTransportFields.vue create mode 100644 src/modules/overseas-business/procurementControlForm.test.ts create mode 100644 src/modules/overseas-business/procurementControlForm.ts create mode 100644 src/modules/overseas-business/procurementFinanceForm.test.ts create mode 100644 src/modules/overseas-business/procurementFinanceForm.ts create mode 100644 src/modules/overseas-business/procurementPending.ts create mode 100644 src/modules/overseas-business/procurementPhysicalForm.test.ts create mode 100644 src/modules/overseas-business/procurementPhysicalForm.ts create mode 100644 src/modules/overseas-business/procurementPresentation.ts create mode 100644 src/modules/overseas-business/procurementTransportForm.test.ts create mode 100644 src/modules/overseas-business/procurementTransportForm.ts create mode 100644 src/modules/overseas-business/procurementTypes.ts diff --git a/src/modules/overseas-business/PROTOTYPE_MATRIX.md b/src/modules/overseas-business/PROTOTYPE_MATRIX.md index 65687119..28fc3ad8 100644 --- a/src/modules/overseas-business/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-business/PROTOTYPE_MATRIX.md @@ -1,5 +1,7 @@ # 海外业务原型核对矩阵 +采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。 + 本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。 ## 池来源本土订单承运安排 diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts index 134c073c..4c14d578 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts @@ -4,9 +4,9 @@ const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn() vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: overseas })) import { - createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder, + createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder, listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases, - listOverseasPurchaseWarehouseOptions, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, + listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder, } from './platformOverseasBusiness' import type { UpdateLocalTransportOrderRequest } from '../types' @@ -15,7 +15,7 @@ const envelope = (data: T) => ({ code: '200', message: 'success', data }) const purchaseSummary = { id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套', productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995', - purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 0, inventoryQuantity: 5, status: 'PENDING_OUTBOUND', version: 4, + purchaseWarehouseName: '采购仓', status: 'WAIT_PACKING', version: 4, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00', } const truckSummary = { @@ -48,23 +48,24 @@ describe('platform overseas business API adapter', () => { }) it('loads and patches procurement detail with three-state null payloads', async () => { - const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] } - overseas.get.mockResolvedValue(envelope(detail)) - expect((await getOverseasPurchase('OP/1', '10001')).histories[0]?.operationType).toBe('UPDATE') + const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] } + overseas.get.mockResolvedValue(envelope({ customerView: { order: purchaseSummary }, reviewHistory: detail.histories, physical: { dispositions: [], receipts: [], cartons: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] } })) + expect((await getOverseasPurchase('OP/1', '10001')).reviewHistory[0]?.operationType).toBe('UPDATE') overseas.patch.mockResolvedValue(envelope(detail)) - const payload = { version: 4, productUrl: null, estimatedArrivalAt: null } + const payload = { version: 4, remark: null } await updateOverseasPurchase('OP-1', '10001', payload) expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } }) }) - it('submits procurement review with version and idempotency data', async () => { - const detail = { summary: { ...purchaseSummary, status: 'APPROVED', version: 5 }, histories: [] } - overseas.post.mockResolvedValue(envelope(detail)) - const payload = { version: 4, decision: 'APPROVE' as const, idempotencyKey: 'review-key', remark: '资料齐全' } - const result = await reviewOverseasPurchase('OP/1', '10001', payload) - expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/review', payload, { params: { customerId: '10001' } }) - expect(result.status).toBe('APPROVED') - expect(result.version).toBe(5) + it('reads the aggregate editor context and strips obsolete mutation fields', async () => { + const detail = { customerView: { order: { orderNo: 'OP/1', customerId: '10001', productName: '外套', purchaseQuantity: 10, version: 4 } }, internalRemark: '内部' } + overseas.get.mockResolvedValue(envelope(detail)) + expect(await getOverseasPurchaseEditor('OP/1', '10001')).toEqual(detail) + expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: '10001' } }) + overseas.patch.mockResolvedValue(envelope({ summary: purchaseSummary })) + const obsolete = { version: 4, remark: '内部', inboundQuantity: 200, purchaseWarehouseId: 'other', productName: 'changed' } + await updateOverseasPurchase('OP/1', '10001', obsolete) + expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } }) }) it('supports platform truck list/detail/create/update without status endpoints', async () => { diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.ts b/src/modules/overseas-business/api/platformOverseasBusiness.ts index d76f6b76..16660e73 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.ts @@ -1,12 +1,13 @@ +import type { PlatformProcurement } from '../procurementTypes' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder, LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, - OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail, + OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest, - UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail, - OverseasPurchaseFile, OverseasPurchaseFee, ReviewOverseasPurchaseRequest, + UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, + OverseasPurchaseEditorDetail, } from '../types' const procurementBasePath = '/platform/v1/last-leg/overseas-procurements' @@ -15,13 +16,7 @@ const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders' type Numeric = number | string interface PagePayload { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } interface ProcurementSummaryPayload extends Omit { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null } -interface ProcurementDetailPayload { - summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null - contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null - overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null - createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null - histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]; files?: any[]; fees?: any[] -} +interface ProcurementDetailPayload { summary: ProcurementSummaryPayload } interface TruckSummaryPayload extends Omit { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric } interface TruckPalletPayload extends Omit { id: Numeric; warehousePalletId?: Numeric | null } interface TruckDetailPayload { @@ -40,9 +35,6 @@ function normalizePage(payload: PagePayload, normal function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary { return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) } } -function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail { - return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })), files: (value.files || []).map((item) => ({ ...item, id: String(item.id), fileId: String(item.fileId), sizeBytes: Number(item.sizeBytes) })), fees: (value.fees || []).map((item) => ({ ...item, id: String(item.id), voucherFileId: String(item.voucherFileId), amount: String(item.amount) })) } -} function truckSummary(value: TruckSummaryPayload): LocalTransportOrder { return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) } } @@ -69,25 +61,33 @@ export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) { return normalizePage(unwrapApiResponse(response), procurementSummary) } export async function getOverseasPurchase(orderNo: string, customerId: string) { - const response = await http.get, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) - return procurementDetail(unwrapApiResponse(response)) + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) + const detail = unwrapApiResponse(response) + if (detail.purchaseFacts?.actual_warehouse_id != null) detail.purchaseFacts.actual_warehouse_id = String(detail.purchaseFacts.actual_warehouse_id) + detail.physical.dispositions = detail.physical.dispositions.map(row => ({ ...row, id: String(row.id), source_carton_id: row.source_carton_id == null ? null : String(row.source_carton_id), receipt_id: row.receipt_id == null ? null : String(row.receipt_id), voucher_file_id: String(row.voucher_file_id) })) + detail.physical.receipts = detail.physical.receipts.map(row => ({ ...row, id: String(row.id), sourceCartonId: row.sourceCartonId == null ? null : String(row.sourceCartonId) })) + detail.physical.cartons = detail.physical.cartons.map(row => ({ ...row, id: String(row.id), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) })) + detail.fees = detail.fees.map(fee => ({ ...fee, id: String(fee.id), parentFeeId: fee.parentFeeId == null ? null : String(fee.parentFeeId), sourceFeeId: fee.sourceFeeId == null ? null : String(fee.sourceFeeId), voucherFileId: fee.voucherFileId == null ? null : String(fee.voucherFileId), walletLedgerId: fee.walletLedgerId == null ? null : String(fee.walletLedgerId) })) + detail.transport.shipments = detail.transport.shipments.map(row => ({ ...row, id: String(row.id) })) + detail.controls.notes = detail.controls.notes.map(row => ({ ...row, id: String(row.id), target_id: row.target_id == null ? null : String(row.target_id), voucher_file_id: String(row.voucher_file_id), resolution_voucher_id: row.resolution_voucher_id == null ? null : String(row.resolution_voucher_id) })) + detail.controls.remaining = detail.controls.remaining.map(row => ({ ...row, sourceId: row.sourceId == null ? null : String(row.sourceId) })) + detail.controls.closures = detail.controls.closures.map(row => ({ ...row, id: String(row.id) })) + detail.controls.closureItems = detail.controls.closureItems.map(row => ({ ...row, id: String(row.id), closure_id: String(row.closure_id), source_id: row.source_id == null ? null : String(row.source_id), voucher_file_id: String(row.voucher_file_id) })) + detail.files = detail.files.map(file => ({ ...file, id: String(file.id), fileId: String(file.fileId) })) + for (const leg of ['overseasCargo', 'domesticCargo'] as const) detail.transport[leg] = detail.transport[leg].map(row => ({ ...row, cartonId: String(row.cartonId), packagingRecordId: row.packagingRecordId == null ? null : String(row.packagingRecordId), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) })) + for (const labels of [detail.transport.overseasLabels, detail.transport.domesticLabels]) if (labels) { + labels.id = String(labels.id); labels.manifestId = String(labels.manifestId) + labels.labels = labels.labels.map(row => ({ ...row, cartonId: String(row.cartonId), fileId: String(row.fileId) })) + } + return detail +} +export async function getOverseasPurchaseEditor(orderNo: string, customerId: string) { + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) + return unwrapApiResponse(response) } export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) { - const response = await http.patch, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) - return procurementDetail(unwrapApiResponse(response)) -} -export async function reviewOverseasPurchase(orderNo: string, customerId: string, payload: ReviewOverseasPurchaseRequest) { - const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } }) - return procurementDetail(unwrapApiResponse(response)) -} -export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) { - const body = new FormData(); body.append('file', file) - const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } }) - return unwrapApiResponse(response) -} -export async function createOverseasPurchaseFee(orderNo: string, customerId: string, payload: { feeItemCode: string; feeItemName: string; amount: number; voucherFileId: string; remark?: string }) { - const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } }) - return unwrapApiResponse(response) + const response = await http.patch, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { version: payload.version, remark: payload.remark }, { params: { customerId } }) + return procurementSummary(unwrapApiResponse(response).summary) } export async function listUsTruckOrders(query: LocalTransportQuery = {}) { const response = await http.get>, ApiEnvelope>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } }) diff --git a/src/modules/overseas-business/api/procurementCommands.test.ts b/src/modules/overseas-business/api/procurementCommands.test.ts new file mode 100644 index 00000000..951e5a56 --- /dev/null +++ b/src/modules/overseas-business/api/procurementCommands.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })) +vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: api })) +import { submitProcurementCommand, uploadProcurementFile, downloadProcurementFile, type ProcurementCommand } from './procurementCommands' +const envelope = (data: T) => ({ code: '200', message: 'success', data }) +beforeEach(() => vi.clearAllMocks()) +describe('procurement node HTTP contract', () => { + it.each(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'control-notes', 'actual-warehouse/correct', 'exception-close'])('sends %s to the scoped order endpoint without interpreting money success', async command => { + api.post.mockResolvedValue(envelope({ status: 'WAIT_OVERSEAS_RECEIPT', purchase: { walletStatus: 'FREEZE_PENDING' } })) + const payload = { version: 3, idempotencyKey: 'same-command', reason: '已核对', amount: '9007199254740993.01' } + await submitProcurementCommand('OP/1', '10001', command, payload) + expect(api.post).toHaveBeenCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${command}`, payload, { params: { customerId: '10001' } }) + }) + it('posts additional expenses without a fee route id or inherited correction fields', async () => { + api.post.mockResolvedValue(envelope({})) + const payload = { version: 3, idempotencyKey: 'additional', reason: 'invoice', name: 'Storage', amount: '3.25', currency: 'USD' as const, businessDate: '2020-01-03', voucherFileId: '501' } + await submitProcurementCommand('OP/1', '10001', 'fees/additional', { ...payload, feeId: '11', targetOriginalAmount: '99' }) + expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/additional', payload, { params: { customerId: '10001' } }) + }) + it('resolves the exact order-owned blocker and does not send the internal route reference as a mutable field', async () => { + api.post.mockResolvedValue(envelope({})) + await submitProcurementCommand('OP/1', '10001', 'control-notes/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', noteId: '71', voucherFileId: '501' }) + expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/control-notes/71/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', voucherFileId: '501' }, { params: { customerId: '10001' } }) + }) + it('uses the exact fee endpoint and sends only fields belonging to that financial operation', async () => { + api.post.mockResolvedValue(envelope({})) + const base = { version: 3, idempotencyKey: 'finance-one', reason: '核对', feeId: '11' } + await submitProcurementCommand('OP/1', '10001', 'fees/correct-target', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501', amount: 'unrelated' }) + expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/correct-target', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } }) + await submitProcurementCommand('OP/1', '10001', 'fees/replace-failed', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501' }) + expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/replace-failed', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } }) + await submitProcurementCommand('OP/1', '10001', 'fees/refund', { ...base, amount: '1.01', voucherFileId: '501' }) + expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/refund', { version: 3, idempotencyKey: 'finance-one', reason: '核对', amount: '1.01', voucherFileId: '501' }, { params: { customerId: '10001' } }) + for (const command of ['fees/retry', 'fees/terminate', 'fees/release-estimate'] as const) { + await submitProcurementCommand('OP/1', '10001', command, base) + expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/${command.slice(5)}`, { version: 3, idempotencyKey: 'finance-one', reason: '核对' }, { params: { customerId: '10001' } }) + } + }) + it('targets an original receipt and nests only the receipt contract plus correction evidence', async () => { + api.post.mockResolvedValue(envelope({})) + const receipt = { version: 3, idempotencyKey: 'correct-one', reason: '实收复核', leg: 'OVERSEAS' as const, warehouseId: '20', goodQuantity: 5, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' } + await submitProcurementCommand('OP/1', '10001', 'receipts/correct', { ...receipt, receiptId: '11', voucherFileId: '901' }) + expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/receipts/11/correct', { receipt: { ...receipt, sourceCartonId: undefined }, voucherFileId: '901' }, { params: { customerId: '10001' } }) + await submitProcurementCommand('OP/1', '10001', 'dispositions/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', dispositionId: '71', voucherFileId: '901' }) + expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/dispositions/71/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', voucherFileId: '901' }, { params: { customerId: '10001' } }) + }) + it('uses purchase-purpose multipart evidence and normalizes file references', async () => { + api.post.mockResolvedValue(envelope({ id: 11, fileId: '9007199254740993', category: 'PURCHASE', fileName: 'purchase.pdf' })) + const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' }) + expect(await uploadProcurementFile('OP/1', '10001', 'PURCHASE', file)).toMatchObject({ id: '11', fileId: '9007199254740993' }) + const call = api.post.mock.calls[0]; expect(call[0]).toBe('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/PURCHASE'); expect(call[1].get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '10001' } }) + }) + it('downloads binary files through the authenticated order boundary', async () => { + const blob = new Blob(['pdf'], { type: 'application/pdf' }); api.get.mockResolvedValue(blob) + expect(await downloadProcurementFile('OP/1', '10001', '901')).toBe(blob) + expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/901/content', { params: { customerId: '10001' }, responseType: 'blob' }) + }) +}) diff --git a/src/modules/overseas-business/api/procurementCommands.ts b/src/modules/overseas-business/api/procurementCommands.ts new file mode 100644 index 00000000..f955ea58 --- /dev/null +++ b/src/modules/overseas-business/api/procurementCommands.ts @@ -0,0 +1,62 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind } from '../procurementTypes' + +export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close' +export interface ProcurementCommandBody { + name?: string + targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string + resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string + feeId?: string; targetOriginalAmount?: string; internationalFreight?: ProcurementFeeSelection; duty?: ProcurementFeeSelection; domesticFreight?: ProcurementFeeSelection + otherDetermination?: 'NOT_APPLICABLE' | 'ITEMIZED'; otherFees?: { code: string; name: string; selection: ProcurementFeeSelection }[] + version: number; idempotencyKey: string; reason?: string; decision?: 'APPROVE' | 'REJECT' | 'ACCEPT_SHORTAGE' | 'DISPOSE_DAMAGED' | 'RETURN_EXTRA'; remark?: string + purchaseWarehouseId?: string; warehouseId?: string; productName?: string; specification?: string + leg?: 'OVERSEAS' | 'DOMESTIC'; sourceCartonId?: string; receiptId?: string; dispositionId?: string + goodQuantity?: number; damagedQuantity?: number; extraQuantity?: number; receivedAt?: string + labelVersion?: number; receiver?: { name: string; phone: string; address: string }; addressVoucherFileId?: string + labels?: { cartonId: string; fileId: string; page: number; carrier: string; trackingNo: string }[] + cartonIds?: string[]; businessDate?: string; appliedAt?: string; shippedAt?: string; cartonId?: string + lengthCm?: string; widthCm?: string; heightCm?: string; grossWeightKg?: string + kind?: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; packingVersion?: number; cartons?: ProcurementPackingCarton[] + quantity?: number; amount?: string; currency?: 'CNY' | 'USD'; purchasedAt?: string; estimatedArrivalAt?: string; voucherFileId?: string +} +export interface ProcurementResolution { kind: ProcurementRemainingKind; sourceId: string | null; quantity: number; decision: string; destination: string; voucherFileId: string } +export interface ProcurementChargeAmount { determination: 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId?: string; reason: string } +export interface ProcurementFeeSelection { feeId?: string; value?: ProcurementChargeAmount } +export interface ProcurementPackingCarton { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number }[] } +const base = '/platform/v1/last-leg/overseas-procurements' +export async function submitProcurementCommand(orderNo: string, customerId: string, command: ProcurementCommand, payload: ProcurementCommandBody) { + let path: string = command; let body: unknown = payload + if (command === 'receipts/correct') { + if (!payload.receiptId || !/^[1-9]\d*$/.test(payload.receiptId)) throw new Error('缺少有效实收来源') + path = `receipts/${payload.receiptId}/correct` + body = { receipt: { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, leg: payload.leg, warehouseId: payload.warehouseId, sourceCartonId: payload.sourceCartonId, goodQuantity: payload.goodQuantity, damagedQuantity: payload.damagedQuantity, extraQuantity: payload.extraQuantity, receivedAt: payload.receivedAt }, voucherFileId: payload.voucherFileId } + } else if (command === 'dispositions/revoke') { + if (!payload.dispositionId || !/^[1-9]\d*$/.test(payload.dispositionId)) throw new Error('缺少有效差异处置来源') + path = `dispositions/${payload.dispositionId}/revoke` + body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId } + } + if (command === 'control-notes/resolve') { + if (!payload.noteId || !/^[1-9]\d*$/.test(payload.noteId)) throw new Error('缺少有效阻断异常来源') + path = `control-notes/${payload.noteId}/resolve` + body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId } + } + if (command === 'fees/additional') body = { version: payload.version, idempotencyKey: payload.idempotencyKey, name: payload.name, amount: payload.amount, currency: payload.currency, businessDate: payload.businessDate, voucherFileId: payload.voucherFileId, reason: payload.reason } + if (command.startsWith('fees/') && command !== 'fees/additional') { + if (!payload.feeId || !/^[1-9]\d*$/.test(payload.feeId)) throw new Error('缺少有效费用来源') + path = `fees/${payload.feeId}/${command.slice(5)}` + body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, + ...(['fees/correct-target', 'fees/replace-failed'].includes(command) ? { targetOriginalAmount: payload.targetOriginalAmount, voucherFileId: payload.voucherFileId } : {}), + ...(command === 'fees/refund' ? { amount: payload.amount, voucherFileId: payload.voucherFileId } : {}) } + } + const response = await http.post, ApiEnvelope>(`${base}/${encodeURIComponent(orderNo)}/${path}`, body, { params: { customerId } }) + unwrapApiResponse(response) +} +export async function uploadProcurementFile(orderNo: string, customerId: string, category: ProcurementFileCategory, file: File) { + const data = new FormData(); data.append('file', file) + const response = await http.post, ApiEnvelope>(`${base}/${encodeURIComponent(orderNo)}/files/${category}`, data, { params: { customerId } }) + const result = unwrapApiResponse(response) + return { ...result, id: String(result.id), fileId: String(result.fileId) } +} +export async function downloadProcurementFile(orderNo: string, customerId: string, fileId: string) { + return http.get(`${base}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content`, { params: { customerId }, responseType: 'blob' }) +} diff --git a/src/modules/overseas-business/components/ProcurementChargeFields.vue b/src/modules/overseas-business/components/ProcurementChargeFields.vue new file mode 100644 index 00000000..ae9706d6 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementChargeFields.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/modules/overseas-business/components/ProcurementControlFields.vue b/src/modules/overseas-business/components/ProcurementControlFields.vue new file mode 100644 index 00000000..90bfb983 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementControlFields.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/modules/overseas-business/components/ProcurementFinanceFields.vue b/src/modules/overseas-business/components/ProcurementFinanceFields.vue new file mode 100644 index 00000000..6ce3cd13 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementFinanceFields.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.test.ts b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts new file mode 100644 index 00000000..e522f75d --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts @@ -0,0 +1,213 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAuthStore } from '@/core/auth/auth' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { ApiResponseError } from '@/shared/api/http' +import AppDialog from '@/shared/components/AppDialog.vue' +import ProcurementNodeActions from './ProcurementNodeActions.vue' +import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' +import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' +import { readPendingProcurement } from '../procurementPending' +import type { PlatformProcurement } from '../procurementTypes' +vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() })) +vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() })) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) +const storageKey = 'procurement-command:7:10001:OP-100' +function storage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: key => values.get(key) ?? null, key: index => [...values.keys()][index] ?? null, removeItem: key => values.delete(key), setItem: (key, value) => values.set(key, value) } } +function fixture(key = 'REVIEW', allowed = true): PlatformProcurement { + const keys: Record = { REVIEW: ['审核采购', 'review'], PURCHASE: ['登记购买完成', 'buy'], AUTHORIZE_BUDGET: ['核验预算上限', 'review'], PLAN_WAREHOUSE: ['修改计划仓', 'review'], CANCEL: ['购买前取消', 'cancel'], RECEIVE_OVERSEAS: ['登记海外实收', 'receive-overseas'], FINISH_DOMESTIC: ['确认国内收货完成', 'receive-domestic'], PACK: ['保存装箱草稿', 'pack'], MANAGE: ['事实更正及异常处置', 'manage'], LABELS: ['维护两段面单', 'label'], ESTIMATE: ['核对预估运费', 'estimate'], SHIP_OVERSEAS: ['确认海外交寄', 'ship-overseas'], CONFIRM_CHARGES: ['确认国内费用', 'charge'], FINANCE_ADJUST: ['费用更正与退款', 'finance-adjust'], EXCEPTION_CLOSE: ['异常关闭', 'manage'] } + const [label, suffix] = keys[key] + return { + customerView: { order: { id: '1', orderNo: 'OP-100', customerId: '10001', customerName: '测试客户', purchaseCategory: '家居', productName: '样品商品', productSpecification: '黑色', productUrl: null, purchaseQuantity: 2, budgetUnitPrice: '10.00', currency: 'USD', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseCode: null, actualWarehouseName: null, status: 'PENDING_REVIEW', version: 3, createdAt: '', updatedAt: '' }, budget: { totalAmount: '20.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, + purchaseFacts: {}, physical: { dispositions: [], receipts: [], cartons: [], packingVersion: null, packingStatus: null }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key, label, permission: `platform:overseas-business:purchase:${suffix}`, allowed, reason: allowed ? null : '业务条件不满足' }], internalRemark: null, + } +} +function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'receive-overseas', 'receive-domestic', 'pack', 'manage', 'label', 'estimate', 'ship-overseas', 'charge', 'finance-adjust']) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.token = 'test-token'; auth.permissions = permissions.map(item => `platform:overseas-business:purchase:${item}`); useAccessPreviewStore().initializeAfterLogin() + return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } }) +} +async function open(wrapper: ReturnType) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) } +async function explain(wrapper: ReturnType, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) } +async function confirm(wrapper: ReturnType) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() } +enableAutoUnmount(afterEach) +afterEach(() => vi.unstubAllGlobals()) +beforeEach(() => { + vi.stubGlobal('sessionStorage', storage()); vi.stubGlobal('localStorage', storage()); document.body.innerHTML = '' + vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外收货仓', countryCode: 'US' }]) + vi.mocked(submitProcurementCommand).mockReset().mockResolvedValue(undefined) + vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }) +}) +describe('procurement purchase node operations', () => { + it('requires explicit remaining-goods outcomes and keeps private closure reasons separate', async () => { + const value = fixture('EXCEPTION_CLOSE'); value.purchaseFacts.purchased_at = '2020-01-01T10:00:00' + value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-100', quantity: 2 }] + value.files = [{ id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }] + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '内部客户沟通及供应商处理依据') + await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() + const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! + field('实际处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SUPPLIER_RETAINED') + field('本项处置凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501') + await field('实际去向及接收方').find('input').setValue('供应商原仓留存') + field('客户同意停止履约凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501') + await field('财务处理结论(仅平台可见)').find('textarea').setValue('全部退款及冻结释放已处理') + await field('对客关闭摘要(客户可见)').find('textarea').setValue('货物由供应商保留,停止后续寄送') + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'exception-close', expect.objectContaining({ resolutions: [{ kind: 'PURCHASE_REMAINDER', sourceId: null, quantity: 2, decision: 'SUPPLIER_RETAINED', destination: '供应商原仓留存', voucherFileId: '501' }], customerVoucherFileId: '501', financialConclusion: '全部退款及冻结释放已处理', clientSummary: '货物由供应商保留,停止后续寄送' })) + expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0) + }) + it('confirms three explicit zero determinations and marks other charges not applicable in one request', async () => { + const value = fixture('CONFIRM_CHARGES'); value.purchaseFacts.domestic_received_at = '2020-01-04T10:00:00' + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '整单费用核对完成') + await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() + const groups = dialog.findAllComponents({ name: 'ProcurementChargeFields' }); expect(groups).toHaveLength(3) + for (const group of groups) { + const determination = group.findAllComponents({ name: 'ElSelect' })[0]; determination.vm.$emit('update:modelValue', 'ZERO'); determination.vm.$emit('change', 'ZERO') + group.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-04') + await group.find('input[placeholder="说明计费依据;零元或不适用也需说明"]').setValue('本项费用免收') + } + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledTimes(1) + const [number, customer, command, payload] = vi.mocked(submitProcurementCommand).mock.calls[0] + expect([number, customer, command]).toEqual(['OP-100', '10001', 'charges/confirm']) + for (const selection of [payload.internationalFreight, payload.duty, payload.domesticFreight]) expect(selection).toEqual({ value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '本项费用免收' } }) + expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([]) + }) + it('lets finance replace a terminated unpaid sample without modifying its original purchase fact', async () => { + const value = fixture('FINANCE_ADJUST') + value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: false, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'TERMINATED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }] + value.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }] + const wrapper = render(value, ['finance-adjust']); const dialog = await open(wrapper) + const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! + field('财务处理方式').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'fees/replace-failed'); await flushPromises() + field('原费用').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await flushPromises() + await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00') + field('本单凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await explain(wrapper, '原资金已终止,按真实凭证重新核定') + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/replace-failed', expect.objectContaining({ feeId: '11', targetOriginalAmount: '12.00', voucherFileId: '501' })) + expect(value.fees[0].originalAmount).toBe('10.00'); expect(value.fees[0].active).toBe(false) + }) + it('lets a buyer retry a failed sample payment without granting ordinary charge or finance-adjust access', async () => { + const value = fixture('PURCHASE') + value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'FREEZE_FAILED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }] + const wrapper = render(value, ['buy']); await wrapper.findAll('button').find(button => button.text() === '重试失败资金请求')!.trigger('click'); await flushPromises(); await explain(wrapper, '客户已补足余额,重试原资金请求') + wrapper.findComponent(AppDialog).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/retry', expect.objectContaining({ feeId: '11', version: 3 })) + expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('amount') + wrapper.unmount(); const restricted = render(value, ['charge']); expect(restricted.text()).not.toContain('重试失败资金请求') + }) + it('uploads an external label and submits a per-carton association with receiving details', async () => { + const value = fixture('LABELS'); value.physical.packingStatus = 'CONFIRMED'; value.physical.packingVersion = 2 + value.transport.overseasCargo = [{ cartonId: '31', cartonNo: 'BOX-31', quantity: 2, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 2 }] }] + vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '51', fileId: '501', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }) + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '国内客服制作国际面单') + const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! + await field('国内接收人').find('input').setValue('国内收货人'); await field('联系电话').find('input').setValue('13800000001'); await field('完整收件地址').find('input').setValue('上海国内收货地址') + const input = dialog.find('input[aria-label="上传快递面单"]'); const file = new File(['pdf'], 'label.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() + field('本单面单文件').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await field('承运商').find('input').setValue('UPS'); await field('本箱运单号').find('input').setValue('TRACK-31') + await confirm(wrapper) + expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'LABEL', file) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'labels', expect.objectContaining({ leg: 'OVERSEAS', packingVersion: 2, receiver: { name: '国内收货人', phone: '13800000001', address: '上海国内收货地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'UPS', trackingNo: 'TRACK-31' }] })) + expect(submitProcurementCommand).toHaveBeenCalledTimes(1) + }) + it('records a partial overseas receipt separately from completing receipt', async () => { + const value = fixture('RECEIVE_OVERSEAS'); value.purchaseFacts = { purchased_at: '2020-01-01T10:00:00', purchased_quantity: 2 } + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '首批样品到仓') + dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001') + dialog.findAllComponents({ name: 'ElInputNumber' })[0].vm.$emit('update:modelValue', 1) + dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-02T10:00:00') + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'overseas-receipts', expect.objectContaining({ version: 3, leg: 'OVERSEAS', warehouseId: '20001', goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' })) + expect(submitProcurementCommand).toHaveBeenCalledTimes(1) + expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('sourceCartonId') + }) + it('saves structured packing sources without invoking packing confirmation', async () => { + const value = fixture('PACK') + value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }] + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '核对装箱草稿') + const values = ['BOX-ONE', '10.12', '20', '30', '1.234'] + for (const [index, input] of dialog.findAll('.packing-grid input').entries()) await input.setValue(values[index]) + dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11') + dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2) + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'packing', expect.objectContaining({ cartons: [{ cartonNo: 'BOX-ONE', lengthCm: '10.12', widthCm: '20', heightCm: '30', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 2 }] }] })) + expect(submitProcurementCommand).toHaveBeenCalledTimes(1) + }) + it('uses exception evidence and source quantities for damaged goods disposition', async () => { + const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2 + value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }] + vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' }) + const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认') + const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! + field('差异及处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DAMAGED'); await flushPromises() + field('对应实收').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11') + dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1) + const input = dialog.find('input[type="file"]'); const file = new File(['img'], 'damage.png', { type: 'image/png' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() + await confirm(wrapper) + expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'EXCEPTION', file) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'dispositions', expect.objectContaining({ receiptId: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucherFileId: '102' })) + }) + it('requires an authoritative warehouse on approval and sends the current order version', async () => { + const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper) + await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓') + dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' }) + expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull() + }) + it('rejects without a warehouse and never substitutes an invented candidate', async () => { + vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable')) + const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求') + dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' })) + expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId') + }) + it('records explicit zero purchase with the exact product snapshot and purchase-purpose evidence', async () => { + const wrapper = render(fixture('PURCHASE')); const dialog = await open(wrapper); await explain(wrapper, '供应商免费提供样品') + await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() + const amount = dialog.findAll('.el-form-item').find(item => item.text().startsWith('供应商实付总额'))!.find('input'); await amount.setValue('0.00') + const dates = dialog.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-08T10:00:00'); dates[1].vm.$emit('update:modelValue', '2026-10-15T10:00:00') + const input = dialog.find('input[type="file"]'); const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises() + expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'PURCHASE', file) + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' })) + }) + it('recovers an uncertain request after reload without changing its body or key', async () => { + vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')) + let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper) + const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount() + wrapper = render(fixture('CANCEL')); await flushPromises() + const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises() + expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull() + }) + it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => { + vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409')) + const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper) + const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull() + await confirm(wrapper) + expect(sessionStorage.getItem(storageKey)).toBe(saved) + expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3]) + expect(wrapper.emitted('changed')).toBeUndefined() + }) + it('keeps a version conflict visible, releases the failed command and does not report success', async () => { + vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409')) + const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper) + expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined() + }) + it('hides actions without permission and disables actions blocked by the server', async () => { + let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount() + wrapper = render(fixture('REVIEW', false)); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined() + }) + it('prevents a second submit while the first command is pending', async () => { + let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve })) + const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper) + wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() + expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises() + }) + it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => { + sessionStorage.setItem(storageKey, '{bad') + const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined() + expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow() + }) +}) diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.vue b/src/modules/overseas-business/components/ProcurementNodeActions.vue new file mode 100644 index 00000000..c695c291 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementNodeActions.vue @@ -0,0 +1,178 @@ + + + diff --git a/src/modules/overseas-business/components/ProcurementPhysicalFields.vue b/src/modules/overseas-business/components/ProcurementPhysicalFields.vue new file mode 100644 index 00000000..423817bc --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementPhysicalFields.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/modules/overseas-business/components/ProcurementTransportFields.vue b/src/modules/overseas-business/components/ProcurementTransportFields.vue new file mode 100644 index 00000000..c2b338c6 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementTransportFields.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/modules/overseas-business/contract.test.ts b/src/modules/overseas-business/contract.test.ts index 55d60820..ff61663b 100644 --- a/src/modules/overseas-business/contract.test.ts +++ b/src/modules/overseas-business/contract.test.ts @@ -14,14 +14,14 @@ describe('overseas business real contract pages', () => { expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/) }) - it('loads procurement candidates, operations and three-state PATCH fields', () => { + it('keeps procurement general editing limited to internal remarks', () => { 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('listOverseasPurchaseWarehouseOptions') - expect(detail).toContain('detail?.histories || []') + expect(edit).toContain('getOverseasPurchaseEditor') + expect(detail).toContain('detail.reviewHistory') expect(detail).not.toContain('outbounds') - expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null') - expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null') + expect(edit).toContain('remark: form.remark.trim() || null') + expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/) expect(edit).not.toContain('入库量不能小于 0') expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/) }) diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index 38939f20..bc6163fb 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -1,3 +1,4 @@ +import AppDialog from '@/shared/components/AppDialog.vue' import ElementPlus from 'element-plus' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' @@ -8,13 +9,13 @@ import { ApiResponseError } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam' -import AppDialog from '@/shared/components/AppDialog.vue' +import type { PlatformProcurement } from '../procurementTypes' import type { FleetArrangement } from '../fleetArrangement' -import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types' +import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types' import { - createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder, + createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder, listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions, - listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder, + listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder, } from '../api/platformOverseasBusiness' import PurchaseOrdersView from './PurchaseOrdersView.vue' import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue' @@ -29,7 +30,7 @@ const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('../api/platformOverseasBusiness', () => ({ - listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), reviewOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(), + listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(), listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(), listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(), })) @@ -38,20 +39,24 @@ vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWor class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } -const purchase: OverseasPurchaseDetail = { +const purchase = { id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product', - purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, outboundQuantity: 0, inventoryQuantity: 10, status: 'PENDING_OUTBOUND', version: 3, - createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null, - overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00', - histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [], + purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3, + createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', + estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00', + histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [], } +const procurementDetail = (): PlatformProcurement => ({ + customerView: { order: { ...purchase, status: 'WAIT_PURCHASE', plannedWarehouseName: '洛杉矶采购仓', actualWarehouseCode: null, actualWarehouseName: null, receivingAddress: '深圳收件地址', contactName: 'Alice', contactPhone: '13800000000' }, budget: { totalAmount: '250.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [{ key: 'CREATED', title: '客户下单', completedAt: purchase.createdAt }], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, + purchaseFacts: {}, physical: { dispositions: [], receipts: [], packingVersion: null, packingStatus: null, cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: purchase.histories, operations: [], actions: [], internalRemark: '采购备注', +}) const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' } const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] } const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' } const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] } const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' } -function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia } +function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia } function mountPage(component: Parameters[0], props?: Record) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) } enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals()) @@ -59,7 +64,8 @@ describe('overseas business mounted pages', () => { beforeEach(() => { 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(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }); vi.mocked(reviewOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, status: 'APPROVED', version: 4 }) + 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' }]) vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail) vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail) @@ -150,46 +156,79 @@ describe('overseas business mounted pages', () => { await flushPromises() expect(wrapper.get('.density-detail-title').text()).toBe('OP-100') expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓') - expect(wrapper.findAll('.density-fields')).toHaveLength(4) - expect(wrapper.findAll('.density-fields dt')).toHaveLength(23) - expect(wrapper.findAll('.density-fields .wide')).toHaveLength(4) + expect(wrapper.findAll('.density-fields')).toHaveLength(3) + expect(wrapper.text()).toContain('预算单价') + expect(wrapper.text()).toContain('两段实际交寄') + expect(wrapper.text()).not.toContain('库存信息') + expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2) expect(wrapper.text()).not.toContain('internal-purchase-route') await wrapper.get('.density-back').trigger('click') expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' }) }) - it('retains the voucher upload and fee dialog workflow with teleported density', async () => { + it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' } - const voucher = { id: 'voucher-1', fileId: 'file-1', fileName: 'voucher.pdf', category: 'VOUCHER' as const, contentType: 'application/pdf', sizeBytes: 3, createdAt: '2026-09-14T10:00:00' } - vi.mocked(uploadOverseasPurchaseFile).mockReset().mockResolvedValue(voucher) - vi.mocked(createOverseasPurchaseFee).mockReset().mockResolvedValue({ id: 'fee-1', feeNo: 'FEE-1', feeItemCode: 'COURIER', feeItemName: '快递费', amount: '25', currency: 'CNY', voucherFileId: 'file-1', voucherFileName: 'voucher.pdf', remark: null, walletStatus: 'FREEZE_PENDING', walletErrorMessage: null, createdAt: '2026-09-14T10:00:00' }) - const wrapper = mountPage(PurchaseOrderDetailView) - await flushPromises() - const input = wrapper.findAll('input[type="file"]')[1]! - const file = new File(['pdf'], 'voucher.pdf', { type: 'application/pdf' }) - Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) - await input.trigger('change'); await flushPromises() - expect(uploadOverseasPurchaseFile).toHaveBeenCalledWith('OP-100', '10001', 'VOUCHER', file) - const dialog = wrapper.findAllComponents(AppDialog)[1]! - expect(dialog.props('modelValue')).toBe(true) - expect(document.querySelector('.admin-density-dialog .density-form')).not.toBeNull() - dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25) - await wrapper.vm.$nextTick() - dialog.vm.$emit('confirm'); await flushPromises() - expect(createOverseasPurchaseFee).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ amount: 25, voucherFileId: 'file-1' })) - expect(dialog.props('modelValue')).toBe(false) + const result = procurementDetail() + result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }] + vi.mocked(getOverseasPurchase).mockResolvedValue(result) + const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises() + expect(wrapper.text()).toContain('已冻结,未扣款') + expect(wrapper.text()).toContain('预估不入池') + expect(wrapper.text()).not.toContain('订单全部入库后结算') }) - 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: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') }) + 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('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) }) it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) }) it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) }) it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') }) it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') }) - it('reviews a pending procurement from its detail page', async () => { const pending = { ...purchase, status: 'PENDING_REVIEW' as const, inboundQuantity: 0, inventoryQuantity: 0, version: 2 }; routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(getOverseasPurchase).mockResolvedValue(pending); vi.mocked(reviewOverseasPurchase).mockResolvedValue({ ...pending, status: 'APPROVED', version: 3 }); const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('通过'))?.trigger('click'); await wrapper.vm.$nextTick(); const reviewDialog = wrapper.findAllComponents(AppDialog)[0]; expect(reviewDialog.props('modelValue')).toBe(true); reviewDialog.vm.$emit('confirm'); await flushPromises(); expect(reviewOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 2, decision: 'APPROVE', idempotencyKey: expect.any(String) })); expect(wrapper.text()).toContain('已通过'); expect(wrapper.find('.header-actions').exists()).toBe(false); expect(wrapper.get('.density-back').text()).toBe('返回列表') }) - it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() }) + + it('submits only the internal remark and preserves input after a version conflict', async () => { + routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' } + vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)) + const wrapper = mountPage(PurchaseOrderEditView); await flushPromises() + await wrapper.find('textarea').setValue('新备注') + await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises() + expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: '新备注' }) + expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试') + expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('新备注') + expect(routing.push).not.toHaveBeenCalled() + }) it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') }) - it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') }) + it('blocks saving until the aggregate context has loaded successfully', async () => { + routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' } + vi.mocked(getOverseasPurchaseEditor).mockRejectedValue(new Error('unavailable')) + const wrapper = mountPage(PurchaseOrderEditView); await flushPromises() + expect(wrapper.text()).toContain('采购订单暂时无法编辑') + expect(wrapper.find('textarea').exists()).toBe(false) + expect(updateOverseasPurchase).not.toHaveBeenCalled() + }) + + it('sends only one remark mutation while a save is pending', async () => { + routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' } + let complete!: (result: Awaited>) => void + vi.mocked(updateOverseasPurchase).mockImplementation(() => new Promise(resolve => { complete = resolve })) + const wrapper = mountPage(PurchaseOrderEditView); await flushPromises() + const button = wrapper.findAll('button').find((item) => item.text().trim() === '保存')! + await button.trigger('click'); await button.trigger('click') + expect(updateOverseasPurchase).toHaveBeenCalledTimes(1) + complete({ ...purchase, version: 4 }); await flushPromises() + expect(routing.push).toHaveBeenCalledTimes(1) + }) + + it('loads one aggregate editor request without warehouse queries and explicitly clears a remark', async () => { + routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' } + const wrapper = mountPage(PurchaseOrderEditView); await flushPromises() + expect(getOverseasPurchaseEditor).toHaveBeenCalledTimes(1) + expect(listOverseasPurchaseWarehouseOptions).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('OP-100') + expect(wrapper.text()).not.toContain('出库明细') + await wrapper.find('textarea').setValue('') + await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises() + expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null }) + expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } }) + }) it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() }) it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') }) it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) }) diff --git a/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue b/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue index f6745f59..75e43af1 100644 --- a/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue +++ b/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue @@ -1,136 +1,187 @@ - - diff --git a/src/modules/overseas-business/pages/PurchaseOrderEditView.vue b/src/modules/overseas-business/pages/PurchaseOrderEditView.vue index 289c047b..16cc6de5 100644 --- a/src/modules/overseas-business/pages/PurchaseOrderEditView.vue +++ b/src/modules/overseas-business/pages/PurchaseOrderEditView.vue @@ -1,10 +1,10 @@ diff --git a/src/modules/overseas-business/pages/PurchaseOrdersView.vue b/src/modules/overseas-business/pages/PurchaseOrdersView.vue index 14480e3a..2f664b51 100644 --- a/src/modules/overseas-business/pages/PurchaseOrdersView.vue +++ b/src/modules/overseas-business/pages/PurchaseOrdersView.vue @@ -1,7 +1,9 @@