feat(procurement): add sample fulfillment operation workspace

This commit is contained in:
2026-10-10 02:19:39 +08:00
parent 2f23e30e20
commit 302cc50a4b
30 changed files with 2107 additions and 301 deletions
@@ -1,5 +1,7 @@
# 海外业务原型核对矩阵
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
## 池来源本土订单承运安排
@@ -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<typeof import('@/shared/api/http')>()), 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 = <T>(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 () => {
@@ -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<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { 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<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
interface TruckDetailPayload {
@@ -40,9 +35,6 @@ function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, 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<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
const response = await http.get<ApiEnvelope<PlatformProcurement>, ApiEnvelope<PlatformProcurement>>(`${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<OverseasPurchaseEditorDetail>, ApiEnvelope<OverseasPurchaseEditorDetail>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) {
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${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<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${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<OverseasPurchaseFile>, ApiEnvelope<OverseasPurchaseFile>>(`${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<OverseasPurchaseFee>, ApiEnvelope<OverseasPurchaseFee>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } })
return unwrapApiResponse(response)
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${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<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(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 } })
@@ -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<typeof import('@/shared/api/http')>()), http: api }))
import { submitProcurementCommand, uploadProcurementFile, downloadProcurementFile, type ProcurementCommand } from './procurementCommands'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
beforeEach(() => vi.clearAllMocks())
describe('procurement node HTTP contract', () => {
it.each<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', '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' })
})
})
@@ -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<unknown>, ApiEnvelope<unknown>>(`${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<ProcurementFile>, ApiEnvelope<ProcurementFile>>(`${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<Blob, Blob>(`${base}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content`, { params: { customerId }, responseType: 'blob' })
}
@@ -0,0 +1,29 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ChargeDraft } from '../procurementFinanceForm'
import { money } from '../procurementPresentation'
const props = defineProps<{ detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ChargeDraft>({ required: true })
const existing = computed(() => props.detail.fees.filter(row => row.active && row.purpose === form.value.purpose && (row.purpose !== 'OTHER' || row.feeItemCode === form.value.code)))
const selected = computed(() => existing.value.find(row => row.id === form.value.feeId))
function determine() { form.value.amount = form.value.determination === 'POSITIVE' ? '' : form.value.determination ? '0.00' : '' }
</script>
<template>
<div class="admin-density charge-fields">
<template v-if="form.mode === 'EXISTING'"><el-form-item label="引用已核定版本" required><el-select v-model="form.feeId" placeholder="选择本单该用途的有效费用"><el-option v-for="row in existing" :key="row.id" :value="row.id" :label="`${row.feeNo} · ${money(row.originalAmount, row.originalCurrency)}`" /></el-select></el-form-item><dl v-if="selected" class="density-fields"><div><dt>原费用版本</dt><dd>{{ selected.feeVersion }}</dd></div><div><dt>原业务日期</dt><dd>{{ selected.businessDate }}</dd></div></dl><p>本次仅核对并引用原费用,不重复扣款。金额更正请使用财务调整。</p></template>
<el-alert v-else-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本项缺少有效版本,请先由财务完成替代核定。" type="warning" :closable="false" />
<template v-else>
<div class="charge-grid"><el-form-item label="核定结果" required><el-select v-model="form.determination" placeholder="明确正额、零元或不适用" @change="determine"><el-option value="POSITIVE" label="确认正额" /><el-option value="ZERO" label="确认零元" /><el-option value="NOT_APPLICABLE" label="不适用" /></el-select></el-form-item><el-form-item label="原币金额" required><el-input v-model="form.amount" :readonly="form.determination === 'ZERO' || form.determination === 'NOT_APPLICABLE'" placeholder="填写实际金额,最多两位小数" /></el-form-item><el-form-item label="币种" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
<el-form-item label="本项费用凭证" :required="form.determination === 'POSITIVE'"><el-select v-model="form.voucherFileId" clearable placeholder="选择已上传的本单费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
<el-form-item label="本项核定说明" required><el-input v-model="form.reason" maxlength="500" placeholder="说明计费依据;零元或不适用也需说明" /></el-form-item>
</template>
</div>
</template>
<style scoped>
.charge-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.charge-fields p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 760px) { .charge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<ControlForm>({ required: true })
const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION'))
const blockers = computed(() => props.detail.controls.notes.filter(note => note.blocking && !note.resolved_at))
</script>
<template>
<div class="admin-density">
<template v-if="command === 'control-notes'">
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
<el-form-item label="对客摘要(客户可见,选填)"><el-input v-model="form.clientSummary" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="仅填写允许客户看到的处理摘要" /></el-form-item>
</template>
<template v-if="command === 'control-notes/resolve'"><el-alert title="解除阻断须填写处理依据并关联本单凭证,原说明与处理记录均保留。" type="info" :closable="false" /><el-form-item label="待解除阻断" required><el-select v-model="form.noteId" placeholder="选择本单未处理的阻断异常"><el-option v-for="note in blockers" :key="note.id" :value="note.id" :label="`${note.note_no} · ${note.reason.slice(0, 60)}`" /></el-select></el-form-item></template>
<template v-if="command === 'actual-warehouse/correct'"><el-alert title="已有箱明细、交寄记录或订单已结束时,本操作仅追加更正说明和阻断,不重新归属货物。是否修改有效仓以服务端历史记录为准。" type="warning" :closable="false" /><dl class="density-fields"><div><dt>原实际收货仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚未记录' }}</dd></div></dl></template>
<template v-if="command === 'exception-close'">
<el-alert title="确认全部实物去向和资金处理后关闭。此操作不自动退款、不生成退货物流,不覆盖原履约事实。" type="warning" :closable="false" />
<section v-for="row in form.resolutions" :key="`${row.kind}:${row.sourceId || ''}`" class="resolution-row"><h3>{{ remainingLabels[row.kind] }} · {{ row.businessNo || '—' }} · {{ row.quantity }} 件</h3><div class="resolution-grid"><el-form-item label="实际处置" required><el-select v-model="row.decision" placeholder="明确实际处置结果"><el-option v-for="value in allowedDecisions[row.kind]" :key="value" :value="value" :label="decisionLabels[value]" /></el-select></el-form-item><el-form-item label="本项处置凭证" required><el-select v-model="row.voucherFileId" placeholder="选择本单对应凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item></div><el-form-item label="实际去向及接收方" required><el-input v-model="row.destination" maxlength="500" placeholder="填写货物实际去向、接收方或确认丢失的依据" /></el-form-item></section>
<p v-if="!form.resolutions.length">服务端当前无待处置的剩余货物,仍需核对客户停止履约凭证和资金处理结论。</p>
<el-form-item label="客户同意停止履约凭证" required><el-select v-model="form.customerVoucherFileId" placeholder="选择本单客户确认凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
<el-form-item label="财务处理结论(仅平台可见)" required><el-input v-model="form.financialConclusion" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="说明扣款、调整、退款及冻结释放的最终处理结果" /></el-form-item>
<el-form-item label="对客关闭摘要(客户可见)" required><el-input v-model="form.clientSummary" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写允许客户看到的最终处理结论" /></el-form-item>
</template>
</div>
</template>
<style scoped>
.resolution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
.resolution-row { padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }
.resolution-row h3 { font-size: var(--admin-font-section); }
:deep(.el-select) { width: 100%; }
@media (max-width: 760px) { .resolution-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, watch } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview, type FinanceForm } from '../procurementFinanceForm'
import { money, decimal, feePurposes } from '../procurementPresentation'
import { commandKey } from '../procurementPending'
import ProcurementChargeFields from './ProcurementChargeFields.vue'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
const form = defineModel<FinanceForm>({ required: true })
const candidates = computed(() => props.detail.fees.filter(fee => {
if (props.command === 'fees/replace-failed') return replacementCandidates(props.detail).some(row => row.id === fee.id)
if (props.command === 'fees/retry') return fee.active && fee.walletStatus.endsWith('_FAILED') && props.can(feeRetryPermission(fee))
if (props.command === 'fees/terminate') return fee.active && fee.walletStatus.endsWith('_FAILED')
if (props.command === 'fees/release-estimate') return fee.active && fee.purpose === 'INTERNATIONAL_ESTIMATE' && ['FROZEN', 'ZERO'].includes(fee.walletStatus)
if (props.command === 'fees/correct-target') return fee.active && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
return fee.walletStatus === 'SETTLED' && !!fee.walletLedgerId && fee.purpose !== 'INTERNATIONAL_ESTIMATE'
}))
watch(candidates, rows => { if (form.value.feeId && !rows.some(row => row.id === form.value.feeId)) form.value.feeId = '' })
const selected = computed(() => props.detail.fees.find(row => row.id === form.value.feeId))
const preview = computed(() => {
if (!selected.value) return null
try {
if (props.command === 'fees/replace-failed' && form.value.targetOriginalAmount) return { ...replacementPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
if (props.command === 'fees/correct-target' && form.value.targetOriginalAmount) return { ...targetPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
if (props.command === 'fees/refund') return { refundable: decimal(refundAvailable(props.detail, selected.value)), error: '' }
} catch (error) { return { error: error instanceof Error ? error.message : '请核对原费用' } }
return null
})
function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = chargeDraft('OTHER', undefined, `OTHER_${commandKey().slice(0, 20).toUpperCase()}`); row.name = ''; form.value.others.push(row) }
</script>
<template>
<div class="admin-density">
<template v-if="command === 'charges/confirm'">
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
</section>
</template>
<template v-else-if="command === 'fees/additional'">
<el-alert title="登记独立新发生的正额费用,使用该业务日期的汇率。与原费用金额更正分开处理;已完成订单保留原履约状态。" type="info" :closable="false" />
<el-form-item label="独立费用名称" required><el-input v-model="form.additional.name" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item>
<el-form-item label="新增费用原币金额" required><el-input v-model="form.additional.amount" placeholder="填写正额金额,最多两位小数" /></el-form-item>
<el-form-item label="新增费用币种" required><el-select v-model="form.additional.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item>
<el-form-item label="新增费用业务日期" required><el-date-picker v-model="form.additional.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="新增费用凭证" required><el-select v-model="form.additional.voucherFileId" placeholder="选择本单已上传的费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
</template>
<template v-else>
<el-alert v-if="command === 'fees/replace-failed'" title="原费用须已明确失败并完成终止及冻结释放。本次按核定总额创建新的实际费用版本,沿用原汇率和业务日期;货款仍受客户预算限制。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/correct-target'" title="输入这条原费用链正确的原币总额。沿用原汇率,按目标与净实扣差额生成追加或逐笔退款;历史扣款不覆盖。" type="info" :closable="false" />
<el-alert v-if="command === 'fees/refund'" title="退款回到客户资金账户,关联选中的原成功扣款;不以普通充值替代,不自动退款其他费用。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
<el-alert v-if="preview?.error" :title="preview.error" type="warning" :closable="false" />
<dl v-else-if="preview" class="density-fields"><template v-if="'target' in preview"><div><dt>目标人民币金额</dt><dd>{{ money(preview.target, 'CNY') }}</dd></div><div><dt>当前净实扣</dt><dd>{{ money(preview.net, 'CNY') }}</dd></div><div><dt>本次差额</dt><dd>{{ money(preview.difference, 'CNY') }}</dd></div></template><div v-if="'refundable' in preview"><dt>该笔剩余可退</dt><dd>{{ money(preview.refundable, 'CNY') }}</dd></div></dl>
</template>
</div>
</template>
<style scoped>
.charge-section { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.charge-section h3 { font-size: var(--admin-font-section); margin: 0 0 var(--admin-space-3); }
.charge-heading { display: flex; align-items: center; gap: var(--admin-space-3); }
.charge-heading :deep(.el-form-item) { flex: 1; }
.other-charge { padding-bottom: var(--admin-space-3); }
:deep(.el-select) { width: 100%; }
</style>
@@ -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<string, string>(); 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<string, [string, string]> = { 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<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
async function confirm(wrapper: ReturnType<typeof render>) { 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()
})
})
@@ -0,0 +1,178 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
import ProcurementControlFields from './ProcurementControlFields.vue'
import { controlCommands, controlForm, buildControl } from '../procurementControlForm'
import ProcurementFinanceFields from './ProcurementFinanceFields.vue'
import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '../procurementFinanceForm'
import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical } from '../procurementPhysicalForm'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
const routes: Record<string, ProcurementCommand> = { REVIEW: 'review', PURCHASE: 'purchase', CONFIRM_PURCHASE: 'purchase/confirm', AUTHORIZE_BUDGET: 'budget-authorizations', PLAN_WAREHOUSE: 'planned-warehouse', CANCEL: 'cancel', RECEIVE_OVERSEAS: 'overseas-receipts', RECEIVE_DOMESTIC: 'domestic-receipts', FINISH_OVERSEAS: 'overseas-receipts/complete', FINISH_DOMESTIC: 'domestic-receipts/complete', PACK: 'packing', CONFIRM_PACKING: 'packing/confirm', MANAGE: 'dispositions', LABELS: 'labels', ESTIMATE: 'international-estimate', APPLY_LABELS: 'labels/apply', SHIP_OVERSEAS: 'overseas-shipment', SHIP_CUSTOMER: 'customer-shipment', REPACK: 'domestic-repack', CONFIRM_CHARGES: 'charges/confirm', FINANCE_ADJUST: 'fees/correct-target', RETRY_FEE: 'fees/retry', EXCEPTION_CLOSE: 'exception-close' }
const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && fee.walletStatus.endsWith('_FAILED') && access.can(feeRetryPermission(fee))))
const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])])
const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false)
const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null)
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
const control = ref(controlForm(props.detail))
const finance = ref(financeForm(props.detail))
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
const transport = ref(transportForm(props.detail))
const physical = ref(physicalForm())
const manageCommand = ref<ProcurementCommand>('dispositions')
const key = computed(() => auth.user?.id ? `procurement-command:${auth.user.id}:${props.customerId}:${props.detail.customerView.order.orderNo}` : '')
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const isControl = computed(() => !!command.value && controlCommands.has(command.value))
const isFinance = computed(() => !!command.value && financeCommands.has(command.value))
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
const files = computed(() => allFiles.value.filter(file => file.category === fileCategory.value))
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purchase-action', label: computed(() => active.value?.label || '采购节点操作'), isOpen: opened, model: reactive({ purchase: form, physical, transport, finance, control }), requiredPermission: computed(() => active.value?.permission ?? null) })
watch(key, () => {
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null
if (!key.value) return
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
}, { immediate: true })
async function loadWarehouses() {
if (warehouseLoading.value) return
warehouseLoading.value = true; warehouseError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
finally { warehouseLoading.value = false }
}
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail)
control.value = controlForm(props.detail)
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
watch(financialCommand, () => { finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null })
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
const order = props.detail.customerView.order
if (!form.reason.trim()) throw new Error('请填写操作说明')
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
if (isPhysical.value && command.value) {
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
}
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
if (needsAmount.value) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
if (!files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单对应用途的凭证')
const amount = { ...base, amount: form.amount.trim(), currency: form.currency, voucherFileId: form.voucherFileId }
if (command.value === 'budget-authorizations') return amount
if (!form.purchasedAt || !form.estimatedArrivalAt) throw new Error('请填写真实购买时间及预计到仓时间')
return { ...amount, productName: order.productName, specification: order.productSpecification ?? '', quantity: order.purchaseQuantity, purchasedAt: form.purchasedAt, estimatedArrivalAt: form.estimatedArrivalAt }
}
return base
}
async function submit() {
if (saving.value || uploading.value || storageError.value || !key.value) return
const resuming = !!pending.value
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
if (!pending.value) {
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
try {
const request = { command: command.value, payload: build() }
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
sessionStorage.setItem(scope, serialized); pending.value = request
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
}
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
saving.value = true; error.value = null
try {
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
sessionStorage.removeItem(scope)
if (key.value !== scope) return
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
} catch (issue) {
if (key.value !== scope) return
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
} finally { saving.value = false }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || uploading.value || pending.value) return
if (file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) { ElMessage.warning('请上传不超过 10MB 的 PDF、JPG 或 PNG'); return }
const scope = key.value; uploading.value = true
try {
const saved = await uploadProcurementFile(props.detail.customerView.order.orderNo, props.customerId, fileCategory.value, file)
if (scope !== key.value) return
extraFiles.value.push(saved); form.voucherFileId = saved.fileId
} catch (issue) { if (scope === key.value) error.value = getApiErrorDetails(issue, '凭证上传失败') }
finally { uploading.value = false }
}
</script>
<template>
<section class="admin-density" aria-label="采购履约节点操作">
<div class="density-actions purchase-actions"><el-tooltip v-for="action in actions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
<el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="submit">确认上次提交结果</el-button></el-alert>
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || !!storageError || !key" :confirm-text="pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || !!pending">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option value="dispositions" label="记录差异处置" /><el-option value="receipts/correct" label="更正原实收数量" /><el-option value="dispositions/revoke" label="撤销原差异处置" /><el-option value="packing/withdraw" label="撤回未交寄的确认清单" /><el-option value="control-notes" label="追加事实说明或阻断" /><el-option value="control-notes/resolve" label="解除已处理的阻断" /><el-option value="actual-warehouse/correct" label="实际仓受控更正" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
<ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" />
<ProcurementFinanceFields v-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" placeholder="填写本次操作的事实、依据或原因" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || !!storageError || !key" @click="submit">{{ pending ? '重试原请求' : '确认提交' }}</el-button></template>
</AppDialog>
</section>
</template>
<style scoped>
.purchase-actions { flex-wrap: wrap; }
.purchase-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.purchase-form-grid :deep(.el-date-editor), :deep(.el-select) { width: 100%; }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, type PhysicalForm } from '../procurementPhysicalForm'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
const sources = computed(() => props.detail.physical.receipts.filter(row => row.active && row.leg === 'OVERSEAS' && row.goodQuantity > 0))
const domesticBoxes = computed(() => shippedCartons(props.detail))
const showReceipt = computed(() => ['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(props.command))
function selectReceipt(id: string) {
if (props.command !== 'receipts/correct') return
const row = props.detail.physical.receipts.find(item => item.id === id && item.active)
if (row) Object.assign(form.value, { leg: row.leg, sourceCartonId: row.sourceCartonId || '', goodQuantity: row.goodQuantity, damagedQuantity: row.damagedQuantity, extraQuantity: row.extraQuantity, receivedAt: row.receivedAt })
}
const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' }
</script>
<template>
<div class="admin-density">
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS">海外收货</el-radio><el-radio value="DOMESTIC">国内收货</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}`" /></el-select></el-form-item>
<template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
</template>
</div>
</template>
<style scoped>
.physical-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3); }
.packing-grid { display: grid; grid-template-columns: 2fr repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
.packing-source { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: var(--admin-space-3); align-items: center; }
.packing-box { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.packing-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); }
:deep(.el-select), :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .physical-grid, .packing-grid { grid-template-columns: 1fr; } .packing-source { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
import type { ProcurementCommand } from '../api/procurementCommands'
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
import { dateTime, measurement, money } from '../procurementPresentation'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
const form = defineModel<TransportForm>({ required: true })
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
const labelSet = computed(() => form.value.leg === 'OVERSEAS' ? props.detail.transport.overseasLabels : props.detail.transport.domesticLabels)
const sender = computed(() => form.value.leg === 'DOMESTIC' ? props.detail.transport.overseasLabels?.receiver : labelSet.value?.sender)
const estimate = computed(() => props.detail.customerView.fees.find(row => row.active && row.purpose === 'INTERNATIONAL_ESTIMATE'))
const changedReceiver = computed(() => form.value.leg === 'DOMESTIC' && (form.value.receiver.name.trim() !== props.detail.customerView.order.contactName || form.value.receiver.phone.trim() !== props.detail.customerView.order.contactPhone || form.value.receiver.address.trim() !== props.detail.customerView.order.receivingAddress))
function changeLeg() { form.value = transportForm(props.detail, form.value.leg) }
function selectCarton(id: string) { const box = props.detail.transport.domesticCargo.find(row => row.cartonId === id); if (box) Object.assign(form.value, { lengthCm: String(box.lengthCm), widthCm: String(box.widthCm), heightCm: String(box.heightCm), grossWeightKg: String(box.grossWeightKg) }) }
</script>
<template>
<div class="admin-density">
<template v-if="command === 'labels'">
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" placeholder="填写快递收件人" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" placeholder="填写真实快递联系电话" /></el-form-item></div>
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" placeholder="填写完整快递收件地址" /></el-form-item>
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
<strong>{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }}</strong>
<div class="label-grid"><el-form-item label="本单面单文件" required><el-select v-model="row.fileId" placeholder="选择 PDF、JPG 或 PNG"><el-option v-for="file in files.filter(item => item.category === 'LABEL')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item><el-form-item label="对应页码" required><el-input-number v-model="row.page" :min="1" :precision="0" controls-position="right" placeholder="图片填 1" /></el-form-item><el-form-item label="承运商" required><el-input v-model="row.carrier" maxlength="128" placeholder="填写实际承运商" /></el-form-item><el-form-item label="本箱运单号" required><el-input v-model="row.trackingNo" maxlength="128" placeholder="填写本箱主/子运单号" /></el-form-item></div>
</section>
</template>
<template v-if="command === 'international-estimate'">
<el-alert title="本次只冻结预估国际运费,不直接扣款。变价时先释放原冻结,释放完成后再次核对并提交新预估;同价也需核对当前面单版本。" type="info" :closable="false" />
<p>当前面单:第 {{ labelSet?.version || '—' }} 版;有效原预估:{{ estimate ? money(estimate.amount, estimate.currency) : '尚未核定' }}</p>
<div class="transport-grid"><el-form-item label="预估国际运费(CNY)" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
</template>
<template v-if="command === 'labels/apply'">
<p>国际面单第 {{ labelSet?.version || '—' }} 版,逐箱记录实际贴单。</p>
<el-form-item label="本次已贴面单的箱" required><el-checkbox-group v-model="form.cartonIds"><el-checkbox v-for="row in labelSet?.labels || []" :key="row.cartonId" :value="row.cartonId" :disabled="!!row.appliedAt">{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }} · {{ row.appliedAt ? `已贴单 ${dateTime(row.appliedAt)}` : '待贴单' }}</el-checkbox></el-checkbox-group></el-form-item>
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<template v-if="command === 'domestic-repack'">
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
</template>
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
<el-alert title="仅在货物实际交给承运方后确认。本次将整批交寄全部可寄原箱,不能拆批或重复交寄。" type="warning" :closable="false" />
<p>面单第 {{ labelSet?.version || '—' }} 版;收件信息:{{ labelSet ? `${labelSet.receiver.name} · ${labelSet.receiver.phone} · ${labelSet.receiver.address}` : '未建立' }}</p>
<el-form-item label="实际交承运方时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
</template>
<table class="density-table"><thead><tr><th>原箱</th><th>当前可寄件数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th></tr></thead><tbody><tr v-for="box in cargo" :key="box.cartonId"><td>{{ box.cartonNo }}</td><td>{{ box.quantity }}</td><td>{{ [box.lengthCm, box.widthCm, box.heightCm].map(value => measurement(value, 2)).join(' × ') }}</td><td>{{ measurement(box.grossWeightKg, 3) }}</td></tr><tr v-if="!cargo.length"><td colspan="4">当前没有可寄货物</td></tr></tbody></table>
</div>
</template>
<style scoped>
.transport-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
.label-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.8fr); gap: var(--admin-space-3); }
.measure-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
.label-row { padding-top: var(--admin-space-3); border-top: 1px solid var(--cc-border); }
:deep(.el-select), :deep(.el-input-number), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 760px) { .transport-grid, .label-grid, .measure-grid { grid-template-columns: 1fr; } }
</style>
@@ -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):/)
})
@@ -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<string, string>(); 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<typeof mount>[0], props?: Record<string, unknown>) { 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<ReturnType<typeof updateOverseasPurchase>>) => 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 })) })
@@ -1,136 +1,187 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, reactive, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
import { downloadProcurementFile } from '../api/procurementCommands'
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
import { remainingLabels, decisionLabels } from '../procurementControlForm'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
const route = useRoute(); const router = useRouter()
const access = useAccessPreviewStore()
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
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<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
{ key: 'operator', label: '操作人', minWidth: 120, formatter: () => '—' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
const downloading = ref<string | null>(null)
const order = computed(() => detail.value?.customerView.order)
const fields = computed(() => [
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
] as const)
const purchaseFields = computed(() => [
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
] as const)
const receiptColumns: DataTableColumn[] = [
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
]
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function openReview(decision: 'APPROVE' | 'REJECT') {
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
const dispositionColumns: DataTableColumn[] = [
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
]
const shipmentColumns: DataTableColumn[] = [
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
]
const feeColumns: DataTableColumn[] = [
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
]
const labelGroups = computed(() => detail.value ? [
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
].filter(group => group.value) : [])
const labelColumns: DataTableColumn[] = [
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
]
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
const noteColumns: DataTableColumn[] = [
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
]
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
const closureItemColumns: DataTableColumn[] = [
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
]
const correctionColumns: DataTableColumn[] = [
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
]
const fileColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
]
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
onBeforeUnmount(() => { generation++ })
function load() {
const currentScope = scope.value
if (inFlight && inFlightScope === currentScope) return inFlight
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
detail.value = null; loadError.value = null; loading.value = false
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
loading.value = true; inFlightScope = currentScope
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
function closeReview() {
if (reviewing.value) return
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
}
async function submitReview() {
if (!detail.value) return
reviewing.value = true
async function download(fileId: unknown) {
const file = detail.value?.files.find(item => item.fileId === fileId)
if (!file || !order.value || downloading.value) return
downloading.value = file.fileId; operationError.value = null; const current = scope.value
try {
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
version: detail.value.version,
decision: reviewDecision.value,
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
remark: reviewRemark.value.trim() || undefined,
})
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
finally { reviewing.value = false }
}
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
uploading.value = true
try { const saved = await uploadOverseasPurchaseFile(detail.value.orderNo, customerId.value, category, file); if (category === 'VOUCHER') { voucher.value = saved; feeDialogOpen.value = true } else { ElMessage.success('签收文件已上传'); await load() } }
catch (error) { ElMessage.error(getApiErrorDetails(error, '文件上传失败').message) } finally { uploading.value = false }
}
async function saveFee() {
if (!detail.value || !voucher.value || feeForm.amount <= 0 || !feeForm.feeItemName.trim()) { ElMessage.warning('请填写正确的费用名称和金额'); return }
savingFee.value = true
try { await createOverseasPurchaseFee(detail.value.orderNo, customerId.value, { feeItemCode: feeForm.feeItemCode.trim(), feeItemName: feeForm.feeItemName.trim(), amount: feeForm.amount, voucherFileId: voucher.value.fileId, remark: feeForm.remark.trim() || undefined }); feeDialogOpen.value = false; voucher.value = null; ElMessage.success('费用已登记并提交资金冻结'); await load() }
catch (error) { ElMessage.error(getApiErrorDetails(error, '费用登记失败').message) } finally { savingFee.value = false }
}
async function load() {
detail.value = null; loadError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
loading.value = true
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
if (scope.value !== current) return
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
finally { downloading.value = null }
}
let activatedOnce = false
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
watch(scope, () => { void load() }, { immediate: true })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="purchase-detail" aria-label="海外采购订单详情">
<template #header>
<div class="density-detail-header">
<div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.orderNo || '—' }}</h1><StatusBadge v-if="detail" :status="statusLabels[detail.status]" /></div>
<div v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="header-actions"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></div>
<AdminPage class="admin-density"><OperationErrorToast :details="operationError" />
<DetailPageLayout class="purchase-detail density-detail-layout" aria-label="采购订单履约详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-grid">
<template v-if="detail">
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
<section><h3>分次实收记录</h3><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></section>
<section><h3>差异处置记录</h3><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版</h3><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
<section><h3>两段实际交寄</h3><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></section>
<section><h3>费用与资金结果</h3><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
<section><h3>费用更正记录</h3><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
<section><h3>事实说明与阻断</h3><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></section>
<section><h3>剩余货物去向核对</h3><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></section>
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></section>
<section><h3>本单文件</h3><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></section>
<section><h3>操作流水</h3><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></section>
</template>
</div>
</template>
<template v-if="detail" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ display(detail.customerName) }}</dd></div><div><dt>采购仓库</dt><dd>{{ display(detail.purchaseWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ display(detail.purchaseQuantity) }}</dd></div><div><dt>入库量</dt><dd>{{ display(detail.inboundQuantity) }}</dd></div><div><dt>出库量</dt><dd>{{ display(detail.outboundQuantity) }}</dd></div><div><dt>库存量</dt><dd>{{ display(detail.inventoryQuantity) }}</dd></div></dl></template>
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-surface">
<div class="detail-grid">
<section><h3>商品信息</h3><dl class="density-fields"><div v-for="field in productFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>库存信息</h3><dl class="density-fields"><div v-for="field in stockFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl class="density-fields"><div v-for="field in orderFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl class="density-fields"><div v-for="field in courierFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout><AppDialog class="admin-density-dialog" v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><p class="review-reference">订单编号:{{ detail?.orderNo || '—' }}</p><el-form class="density-form review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form class="density-form" label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.purchase-detail { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.header-actions, .section-actions { display: flex; align-items: center; gap: var(--admin-space-3); }
.detail-surface { min-width: 0; padding: var(--admin-space-5); }
.purchase-detail { border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid .density-fields > .wide { grid-column: span 2; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.section-heading h3 { margin: 0; }
.section-heading span, .review-reference { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.review-reference { margin: var(--admin-space-3) 0; }
:deep(.el-input-number) { width: 100%; }
.detail-grid p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.density-fields > .wide { grid-column: span 2; }
</style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -13,87 +13,78 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getOverseasPurchaseEditor, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseOutboundDetail, OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel {
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; version: number
outboundDetails: OverseasPurchaseOutboundDetail[]
}
const route = useRoute(); const router = useRouter()
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
const loading = ref(false); const saving = ref(false); const loaded = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0, outboundDetails: [] })
const form = reactive<PurchaseEditModel>(initial())
const rules: FormRules<PurchaseEditModel> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
}
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
const inboundEditable = computed(() => ['APPROVED', 'PENDING_OUTBOUND', 'PARTIALLY_OUTBOUND', 'OUTBOUNDED'].includes(form.status))
const outboundQuantity = computed(() => form.outboundDetails.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0))
function addOutbound() { form.outboundDetails.push({ id: `new-${Date.now()}-${form.outboundDetails.length}`, quantity: 0, carrier: null, trackingNo: null, shippedAt: null, remark: null }) }
function removeOutbound(index: number) { form.outboundDetails.splice(index, 1) }
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
async function loadWarehouses() {
warehousesLoading.value = true; warehousesError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouses.value = []; warehousesError.value = '采购仓候选加载失败,请稍后重试' }
finally { warehousesLoading.value = false }
}
const initial = () => ({ orderNo: '', productName: '', purchaseQuantity: 0, remark: '', version: 0 })
const form = reactive(initial())
const canSave = computed(() => loaded.value && !loading.value && !saving.value && access.can(overseasBusinessPermissions.purchaseEdit))
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑采购内部备注'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
let generation = 0
onBeforeUnmount(() => { generation++ })
function back() { return router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: purchaseId.value }, query: { customerId: customerId.value } }) }
async function load() {
Object.assign(form, initial()); loadError.value = null; submitError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
const current = ++generation; const orderNo = purchaseId.value; const customer = customerId.value
loaded.value = false; loading.value = false; Object.assign(form, initial()); captureBaseline()
loadError.value = null; submitError.value = null
if (!orderNo || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
loading.value = true
try {
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version, outboundDetails: (detail.outboundDetails || []).map((item) => ({ ...item })) })
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
const detail = await getOverseasPurchaseEditor(orderNo, customer)
if (current !== generation) return
const order = detail.customerView.order
Object.assign(form, { orderNo: order.orderNo, productName: order.productName, purchaseQuantity: order.purchaseQuantity, version: order.version, remark: detail.internalRemark ?? '' })
loaded.value = true; captureBaseline()
} catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '采购订单加载失败') }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!await formRef.value?.validate().catch(() => false)) return
if (outboundQuantity.value > form.inboundQuantity) { ElMessage.error('出库总量不能超过入库量'); return }
if (!canSave.value) return
saving.value = true; submitError.value = null
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null, outboundDetails: form.outboundDetails.map((item) => ({ ...item, quantity: Number(item.quantity) || 0, carrier: item.carrier?.trim() || null, trackingNo: item.trackingNo?.trim() || null, remark: item.remark?.trim() || null })) }
const current = generation; const orderNo = purchaseId.value; const customer = customerId.value
try {
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
const updated = await updateOverseasPurchase(orderNo, customer, { version: form.version, remark: form.remark.trim() || null })
if (current !== generation) return
form.version = updated.version; captureBaseline(); ElMessage.success('内部备注已保存')
await back()
} catch (error) { if (current === generation) submitError.value = getApiErrorDetails(error, '内部备注保存失败') }
finally { saving.value = false }
}
watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { immediate: true })
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" aria-label="编辑海外采购订单">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回详情</el-button><h1 class="density-detail-title">编辑海外采购订单</h1><span class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection class="density-form-section" title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" placeholder="请输入商品规格" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" placeholder="请输入入库数量" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" placeholder="请输入国内承运商" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" placeholder="请输入国内物流单号" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" placeholder="请输入海外承运商" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" placeholder="请输入海外物流单号" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button class="density-action" data-tone="primary" size="small" @click="addOutbound">添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" placeholder="请输入出库数量" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="请输入快递或物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" placeholder="请输入物流单号" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" placeholder="请输入本次出库备注(选填)" /></el-form-item><el-button class="remove-outbound density-action" data-tone="danger" @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`">删除</el-button></div></FormSection>
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
<AdminPage class="admin-density overseas-business-editor">
<OperationErrorToast :details="submitError" />
<DetailPageLayout class="overseas-business-form-layout" aria-label="编辑采购内部备注">
<template #header><div class="density-detail-header"><div class="density-detail-heading">
<el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回详情</el-button>
<h1 class="density-detail-title">编辑采购内部备注</h1><span class="form-reference">{{ form.orderNo || '—' }}</span>
</div></div></template>
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="form-panel">
<el-form class="density-form" :model="form" label-position="top" :disabled="!canSave">
<FormSection class="density-form-section" title="采购订单"><div class="form-grid">
<el-form-item label="订单编号"><el-input :model-value="form.orderNo || '—'" readonly /></el-form-item>
<el-form-item label="商品名称"><el-input :model-value="form.productName || '—'" readonly /></el-form-item>
<el-form-item label="采购数量"><el-input :model-value="loaded ? form.purchaseQuantity : '—'" readonly /></el-form-item>
<el-form-item class="span-3" label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="仅供平台内部使用" /></el-form-item>
</div></FormSection>
</el-form>
<footer class="form-actions"><el-button type="primary" :disabled="!canSave" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="back">取消</el-button></footer>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: 1 / -1; }
.outbound-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-body); }
.outbound-head b { color: var(--cc-text); }
.empty-outbound { padding: var(--admin-space-5); color: var(--cc-muted); text-align: center; }
.outbound-row { display: grid; grid-template-columns: 110px minmax(0,1fr) minmax(0,1fr) 190px minmax(0,1fr) 60px; gap: var(--admin-space-3); align-items: start; padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); }
.outbound-row :deep(.el-form-item) { margin-bottom: 0; }
.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width: 100%; }
.remove-outbound { align-self: end; }
@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
</style>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/core/auth/auth'
import { procurementStatuses, statusLabel, money } from '../procurementPresentation'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -14,16 +16,17 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
const router = useRouter(); const access = useAccessPreviewStore()
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
@@ -33,24 +36,30 @@ const columns: DataTableColumn[] = [
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
]
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() {
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
onBeforeUnmount(() => { generation++ })
function load() {
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
const key = JSON.stringify([auth.user?.id, query])
if (inFlight && key === requestKey) return inFlight
requestKey = key; const current = ++generation
loading.value = true; loadError.value = null
try {
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
const promise = listOverseasPurchases(query).then(result => {
if (current !== generation) return
rows.value = result.records; total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
}).catch(error => { if (current === generation) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '采购订单加载失败,请稍后重试') } }).finally(() => {
if (current === generation) loading.value = false
if (inFlight === promise) inFlight = null
})
inFlight = promise; return promise
}
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() })
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { buildControl, controlForm } from './procurementControlForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementControlNote, ProcurementFile } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'control-one', reason: '内部处理依据' }
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
function detail(): PlatformProcurement {
return {
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key: 'EXCEPTION_CLOSE', label: '异常关闭', permission: 'platform:overseas-business:purchase:manage', allowed: true, reason: null }], internalRemark: null,
}
}
function note(): ProcurementControlNote { return { id: '71', note_no: 'PN-71', target_type: 'PURCHASE', target_id: null, blocking: true, reason: '待处理', client_summary: null, created_at: '', resolved_at: null, resolution_reason: null, voucher_file_id: '501', resolution_voucher_id: null, original_value_json: null, corrected_value_json: null } }
function closure() { const value = detail(); value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-1', quantity: 3 }, { kind: 'GOOD', sourceId: '11', businessNo: 'PR-11', quantity: 6 }]; const form = controlForm(value); form.customerVoucherFileId = '501'; form.clientSummary = '客户同意停止寄送'; form.financialConclusion = '所有资金处理已结束'; for (const row of form.resolutions) Object.assign(row, { decision: row.kind === 'PURCHASE_REMAINDER' ? 'SUPPLIER_RETAINED' : 'CUSTOMER_AUTHORIZED_RETENTION', destination: '明确的接收方与留存地点', voucherFileId: '501' }); return { value, form } }
describe('procurement exception control requests', () => {
it('adds a purchase note without leaking the internal reason into a customer summary', () => {
const value = detail(); const form = controlForm(value); form.targetId = 'stale-receipt'
const payload = buildControl('control-notes', form, value, [proof], base, '', '501')
expect(payload).toEqual({ ...base, targetType: 'PURCHASE', blocking: true, voucherFileId: '501' })
expect(payload).not.toHaveProperty('clientSummary'); expect(payload).not.toHaveProperty('targetId')
expect(readPendingProcurement(JSON.stringify({ command: 'control-notes', payload }))).not.toBeNull()
})
it('requires actual order-owned facts and allows notes against historical receipts', () => {
const value = detail(); const form = controlForm(value); form.targetType = 'RECEIPT'; form.targetId = 'other'
expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实收')
form.targetId = '11'; value.physical.receipts[0].active = false; expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('11')
value.transport.shipments = [{ id: '41', shipmentNo: 'PS-41', leg: 'OVERSEAS', shippedAt: '2020-01-03T10:00:00' }]; form.targetType = 'SHIPMENT'; form.targetId = '41'
expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('41')
form.targetId = '42'; expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实际交寄')
})
it('only resolves a still-active blocker with order evidence', () => {
const value = detail(); value.controls.notes = [note()]; const form = controlForm(value); form.noteId = '71'
expect(buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toEqual({ ...base, noteId: '71', voucherFileId: '501' })
value.controls.notes[0].resolved_at = '2020-01-03T10:00:00'; expect(() => buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toThrow('尚未处理')
})
it('requires a distinct actual warehouse and sends no direct inventory mutation', () => {
const value = detail(); const form = controlForm(value)
expect(() => buildControl('actual-warehouse/correct', form, value, [proof], base, '20', '501')).toThrow('不同')
expect(buildControl('actual-warehouse/correct', form, value, [proof], base, '30', '501')).toEqual({ ...base, warehouseId: '30', voucherFileId: '501' })
})
it('closes with exact remaining sources, explicit destinations and separate private/public conclusions', () => {
const { value, form } = closure(); const payload = buildControl('exception-close', form, value, [proof], base, '', '')
expect(payload.resolutions?.map(row => [row.kind, row.sourceId, row.quantity])).toEqual([['PURCHASE_REMAINDER', null, 3], ['GOOD', '11', 6]])
expect(payload).toMatchObject({ customerVoucherFileId: '501', financialConclusion: '所有资金处理已结束', clientSummary: '客户同意停止寄送' })
expect(payload).not.toHaveProperty('amount'); expect(payload).not.toHaveProperty('status')
expect(readPendingProcurement(JSON.stringify({ command: 'exception-close', payload }))).toEqual({ command: 'exception-close', payload })
})
it('rejects omitted, duplicated or changed remaining quantities instead of forcing closure', () => {
let { value, form } = closure(); form.resolutions.pop(); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('去向未明确')
;({ value, form } = closure()); form.resolutions.push({ ...form.resolutions[0] }); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('不可重复')
;({ value, form } = closure()); form.resolutions[0].quantity = 2; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('改数量')
})
it('rejects wrong source dispositions, missing evidence and financial blockers', () => {
let { value, form } = closure(); form.resolutions[0].decision = 'DISPOSED'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('来源不匹配')
;({ value, form } = closure()); form.customerVoucherFileId = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('客户确认凭证')
;({ value, form } = closure()); value.actions[0].allowed = false; value.actions[0].reason = '未决资金请求'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未决资金请求')
;({ value, form } = closure()); value.controls.notes = [note()]; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未解除')
})
it('allows an empty remaining list only with the required customer and financial conclusions', () => {
const value = detail(); const form = controlForm(value); form.customerVoucherFileId = '501'; form.financialConclusion = '无未决费用或冻结'; form.clientSummary = '履约已停止'
expect(buildControl('exception-close', form, value, [proof], base, '', '').resolutions).toEqual([])
form.financialConclusion = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('财务处理结论')
})
})
@@ -0,0 +1,51 @@
import type { PlatformProcurement, ProcurementFile, ProcurementRemaining, ProcurementRemainingKind } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
export const controlCommands = new Set<ProcurementCommand>(['control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
export const remainingLabels: Record<ProcurementRemainingKind, string> = { PURCHASE_REMAINDER: '采购未到货', TRANSIT_REMAINDER: '国际段未到货', GOOD: '合格货物', DAMAGED: '破损货物', EXTRA: '多到货物' }
export const decisionLabels: Record<string, string> = { SUPPLIER_RETAINED: '供应商保留', RETURNED_TO_SUPPLIER: '已退回供应商', RETURNED_TO_SENDER: '已退回寄件方', LOSS_CONFIRMED: '已确认丢失', CUSTOMER_AUTHORIZED_RETENTION: '客户授权留存', DISPOSED: '已处置' }
export const allowedDecisions: Record<ProcurementRemainingKind, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
export interface ResolutionDraft extends ProcurementRemaining { decision: string; destination: string; voucherFileId: string }
export interface ControlForm { targetType: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId: string; blocking: boolean; clientSummary: string; noteId: string; customerVoucherFileId: string; financialConclusion: string; resolutions: ResolutionDraft[] }
export function controlForm(detail: PlatformProcurement): ControlForm { return { targetType: 'PURCHASE', targetId: '', blocking: true, clientSummary: '', noteId: '', customerVoucherFileId: '', financialConclusion: '', resolutions: detail.controls.remaining.map(row => ({ ...row, decision: '', destination: '', voucherFileId: '' })) } }
function proof(files: ProcurementFile[], id: string) { if (!files.some(row => row.fileId === id && row.category === 'EXCEPTION')) throw new Error('请选择本单异常或客户确认凭证') }
function sourceKey(row: { kind: string; sourceId: string | null }) { return `${row.kind}:${row.sourceId ?? ''}` }
export function buildControl(command: ProcurementCommand, form: ControlForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
if (command === 'control-notes') {
let targetId: string | undefined
if (form.targetType === 'PURCHASE') { if (!detail.purchaseFacts.purchased_at) throw new Error('尚无真实购买事实,不能关联购买说明') }
else if (form.targetType === 'WAREHOUSE') { if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际收货仓'); targetId = detail.purchaseFacts.actual_warehouse_id }
else if (form.targetType === 'RECEIPT') { if (!detail.physical.receipts.some(row => row.id === form.targetId)) throw new Error('请选择本单实收记录'); targetId = form.targetId }
else { if (!detail.transport.shipments.some(row => row.id === form.targetId)) throw new Error('请选择本单实际交寄记录'); targetId = form.targetId }
proof(files, voucherFileId)
if (form.clientSummary.trim().length > 500) throw new Error('对客摘要最多 500 字')
return { ...base, targetType: form.targetType, ...(targetId ? { targetId } : {}), blocking: form.blocking, voucherFileId, ...(form.clientSummary.trim() ? { clientSummary: form.clientSummary.trim() } : {}) }
}
if (command === 'control-notes/resolve') {
if (!detail.controls.notes.some(note => note.id === form.noteId && note.blocking && !note.resolved_at)) throw new Error('请选择本单尚未处理的阻断异常')
proof(files, voucherFileId); return { ...base, noteId: form.noteId, voucherFileId }
}
if (command === 'actual-warehouse/correct') {
if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际仓,请使用计划仓调整')
if (!warehouseId || warehouseId === detail.purchaseFacts.actual_warehouse_id) throw new Error('请选择与原实际仓不同的有效海外收货仓')
proof(files, voucherFileId); return { ...base, warehouseId, voucherFileId }
}
const action = detail.actions.find(row => row.key === 'EXCEPTION_CLOSE')
if (!action?.allowed) throw new Error(action?.reason || '尚不满足异常关闭条件')
if (!detail.purchaseFacts.purchased_at) throw new Error('购买前请使用取消订单')
if (detail.controls.notes.some(note => note.blocking && !note.resolved_at)) throw new Error('请先处理本单未解除的阻断异常')
proof(files, form.customerVoucherFileId)
const expected = new Map(detail.controls.remaining.map(row => [sourceKey(row), row]))
const resolutions = form.resolutions.map(row => {
const source = expected.get(sourceKey(row))
if (!source || !Number.isSafeInteger(row.quantity) || row.quantity <= 0 || source.quantity !== row.quantity) throw new Error('处置必须恰好覆盖每份剩余来源,不可重复、跨单或改数量')
expected.delete(sourceKey(row))
if (!allowedDecisions[row.kind].includes(row.decision)) throw new Error('处置方式与剩余货物来源不匹配')
if (!row.destination.trim() || row.destination.trim().length > 500) throw new Error('请填写每份货物的实际去向及接收方,最多 500 字')
proof(files, row.voucherFileId)
return { kind: row.kind, sourceId: row.sourceId, quantity: row.quantity, decision: row.decision, destination: row.destination.trim(), voucherFileId: row.voucherFileId }
})
if (expected.size) throw new Error('仍有剩余货物去向未明确')
if (!form.financialConclusion.trim() || form.financialConclusion.trim().length > 1000) throw new Error('请填写财务处理结论,最多 1000 字')
if (!form.clientSummary.trim() || form.clientSummary.trim().length > 500) throw new Error('请填写对客关闭摘要,最多 500 字')
return { ...base, resolutions, customerVoucherFileId: form.customerVoucherFileId, financialConclusion: form.financialConclusion.trim(), clientSummary: form.clientSummary.trim() }
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { buildFinance, financeForm, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview } from './procurementFinanceForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'finance-one', reason: '凭证核对完成' }
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
function fee(id = '1', purpose = 'DUTY'): ProcurementInternalFee { return { id, feeNo: `PF-${id}`, purpose, confirmationOrigin: 'BUSINESS', feeItemCode: purpose, feeItemName: purpose, feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7.000000', exchangeRateMonth: '2020-01', businessDate: '2020-01-02', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'SETTLED', walletLedgerId: `900${id}`, walletErrorCode: null, walletErrorMessage: null, remark: null } }
function detail(fees: ProcurementInternalFee[] = []): PlatformProcurement {
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseName: null, actualWarehouseCode: null, status: 'WAIT_CHARGES', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: fees.map(row => ({ feeNo: row.feeNo, purpose: row.purpose, name: row.feeItemName, version: row.feeVersion, active: row.active, determination: row.determination, amount: row.amount, currency: row.currency, originalAmount: row.originalAmount, originalCurrency: row.originalCurrency, exchangeRate: row.exchangeRate, exchangeRateMonth: row.exchangeRateMonth, businessDate: row.businessDate, parentFeeNo: null, walletStatus: row.walletStatus, sourceLinked: ['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE'].includes(row.walletStatus) })), exceptionSummaries: [] }, purchaseFacts: { domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 1, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, files: [proof], fees, feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, reviewHistory: [], operations: [], actions: [], internalRemark: null }
}
function chain() { const root = fee(); const extra = { ...fee('2', 'ADJUSTMENT'), amount: '20.00', originalAmount: '20.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1' }; const refund = { ...fee('3', 'REFUND'), amount: '10.00', originalAmount: '10.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1', walletStatus: 'REFUNDED' }; return detail([root, extra, refund]) }
describe('procurement fee determination and correction rules', () => {
it('builds independent additional fees with a scoped voucher and recoverable payload', () => {
const value = detail(); value.purchaseFacts.purchased_at = '2020-01-02T10:00:00'
const form = financeForm(value)
Object.assign(form.additional, { name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
const payload = buildFinance('fees/additional', form, value, [proof], base, '')
expect(payload).toEqual({ ...base, name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
expect(readPendingProcurement(JSON.stringify({ command: 'fees/additional', payload }))).toEqual({ command: 'fees/additional', payload })
expect(() => buildFinance('fees/additional', form, value, [], base, '')).toThrow('凭证')
form.additional.businessDate = '2999-01-01'
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('真实业务日期')
delete value.purchaseFacts.purchased_at
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('已发生购买')
})
it('prevents ordinary creation after initial confirmation', () => {
const value = detail(); value.operations.push({ action: 'CONFIRM_CHARGES' } as PlatformProcurement['operations'][number])
expect(() => buildFinance('charges/confirm', financeForm(value), value, [proof], base, '')).toThrow('首次核费后')
expect(feeRetryPermission({ ...fee(), confirmationOrigin: 'FINANCIAL' })).toContain('finance-adjust')
})
it('requires every fixed determination and does not treat missing values as zero', () => {
const value = detail(); const form = financeForm(value)
expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('未知不能视为零元')
for (const row of form.fixed) Object.assign(row, { determination: 'ZERO', amount: '0.00', businessDate: '2020-01-04', reason: '明确免收' })
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight?.value?.determination).toBe('ZERO'); expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([])
expect(readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload }))).toEqual({ command: 'charges/confirm', payload })
})
it('requires evidence for a positive amount and validates zero and not-applicable amounts', () => {
const value = detail(); const form = financeForm(value)
for (const row of form.fixed) Object.assign(row, { determination: 'NOT_APPLICABLE', amount: '0.00', businessDate: '2020-01-04', reason: '不适用' })
Object.assign(form.fixed[0], { determination: 'POSITIVE', amount: '10.01' })
expect(() => buildFinance('charges/confirm', form, value, [], base, '')).toThrow('费用凭证')
form.fixed[0].voucherFileId = '501'; const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight?.value).toMatchObject({ amount: '10.01', voucherFileId: '501' })
form.fixed[1].amount = '2.00'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('0.00')
})
it('reuses immutable existing fee versions without sending new charge values', () => {
const value = detail([fee('1', 'INTERNATIONAL_FREIGHT'), fee('2', 'DUTY'), fee('3', 'DOMESTIC_FREIGHT'), fee('4', 'OTHER')]); const form = financeForm(value)
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
expect(payload.internationalFreight).toEqual({ feeId: '1' }); expect(payload.duty).toEqual({ feeId: '2' }); expect(payload.domesticFreight).toEqual({ feeId: '3' }); expect(payload.otherFees?.[0].selection).toEqual({ feeId: '4' })
form.others = []; form.otherDetermination = 'NOT_APPLICABLE'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('省略有效费用')
})
it('calculates a target using the original frozen rate and net successful chain only', () => {
const value = chain(); expect(targetPreview(value, value.fees[0], '12.00')).toEqual({ target: '84.00', net: '80.00', difference: '4.00' })
const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501')).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' })
expect(() => targetPreview(value, value.fees[1], '12.00')).toThrow('原实际费用')
})
it('treats actual international freight as the correction root even when it references the original estimate', () => {
const actual = { ...fee('2', 'INTERNATIONAL_FREIGHT'), parentFeeId: '1' }
const estimate = { ...fee('1', 'INTERNATIONAL_ESTIMATE'), amount: '100.00', walletStatus: 'SETTLED' }
const value = detail([estimate, actual]); const form = financeForm(value); form.feeId = '2'; form.targetOriginalAmount = '12.00'
expect(targetPreview(value, actual, '12.00')).toEqual({ target: '84.00', net: '70.00', difference: '14.00' })
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501').feeId).toBe('2')
})
it('replaces only the latest terminated unpaid version using its frozen rate and preserves original amounts', () => {
const original = { ...fee('1', 'INTERNATIONAL_FREIGHT'), active: false, walletStatus: 'TERMINATED', walletLedgerId: null }
const value = detail([original]); const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
expect(replacementPreview(value, original, '12.00')).toEqual({ target: '84.00', net: '0.00', difference: '84.00' })
const payload = buildFinance('fees/replace-failed', form, value, [proof], base, '501')
expect(payload).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' }); expect(readPendingProcurement(JSON.stringify({ command: 'fees/replace-failed', payload }))).not.toBeNull()
value.fees.push({ ...fee('2', 'INTERNATIONAL_FREIGHT'), feeVersion: 2, walletStatus: 'FREEZE_PENDING' })
expect(replacementCandidates(value)).toEqual([]); expect(original.amount).toBe('70.00')
})
it('keeps sample replacement within customer authorization and makes financial replacements retry under finance permission', () => {
const original = { ...fee('1', 'SAMPLE_PAYMENT'), active: false, walletStatus: 'RELEASED' }; const value = detail([original])
expect(() => replacementPreview(value, original, '12.00')).toThrow('客户授权预算')
value.customerView.budget.totalAmount = '100.00'; expect(replacementPreview(value, original, '12.00').target).toBe('84.00')
const replacement = { ...fee('2', 'SAMPLE_PAYMENT'), confirmationOrigin: 'FINANCIAL' as const, walletStatus: 'FREEZE_FAILED', feeVersion: 2 }; value.fees.push(replacement)
value.feeCorrections = [{ correctionNo: 'PC-1', rootFeeNo: 'PF-1', targetOriginalAmount: '12.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', targetAmount: '84.00', previousNetAmount: '0.00', differenceAmount: '84.00', feeNos: ['PF-2'] }]
expect(feeRetryPermission(replacement)).toBe('platform:overseas-business:purchase:finance-adjust')
})
it('preserves cent accuracy beyond JavaScript safe integers and rounds half up', () => {
const value = detail([fee()]); value.fees[0].exchangeRate = '1.000000'
expect(targetPreview(value, value.fees[0], '90071992547409.93')).toEqual({ target: '90071992547409.93', net: '70.00', difference: '90071992547339.93' })
value.fees[0].exchangeRate = '7.000000'; expect(() => targetPreview(value, value.fees[0], '90071992547409.93')).toThrow('超出支持范围')
value.fees[0].exchangeRate = '0.500000'; expect(targetPreview(value, value.fees[0], '0.01').target).toBe('0.01')
})
it('blocks a new target while any related adjustment remains unsettled or unlinked', () => {
const value = chain(); value.customerView.fees[1].sourceLinked = false
expect(() => targetPreview(value, value.fees[0], '12.00')).toThrow('未完成的调整')
})
it('caps refunds against the selected original consumption without borrowing another charge balance', () => {
const value = chain(); expect(refundAvailable(value, value.fees[0])).toBe(6000n); expect(refundAvailable(value, value.fees[1])).toBe(2000n)
const form = financeForm(value); form.feeId = '1'; form.refundAmount = '60.00'
const payload = buildFinance('fees/refund', form, value, [proof], base, '501'); expect(payload.amount).toBe('60.00')
expect(readPendingProcurement(JSON.stringify({ command: 'fees/refund', payload }))).not.toBeNull()
form.refundAmount = '60.01'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('剩余可退')
form.refundAmount = '0.00'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('正额')
})
it('selects retry permission by fee purpose and rejects unknown outcomes', () => {
const value = detail([{ ...fee('1', 'SAMPLE_PAYMENT'), walletStatus: 'FREEZE_FAILED' }]); const form = financeForm(value); form.feeId = '1'
expect(feeRetryPermission(value.fees[0])).toBe('platform:overseas-business:purchase:buy')
expect(buildFinance('fees/retry', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
value.fees[0].walletStatus = 'FREEZE_PENDING'; expect(() => buildFinance('fees/retry', form, value, [], base, '')).toThrow('未知结果')
expect(feeRetryPermission(fee('2', 'INTERNATIONAL_ESTIMATE'))).toContain(':estimate'); expect(feeRetryPermission(fee('3', 'REFUND'))).toContain(':finance-adjust')
})
it('only releases an effective estimate and does not construct a refund request', () => {
const value = detail([{ ...fee('1', 'INTERNATIONAL_ESTIMATE'), walletStatus: 'FROZEN' }]); const form = financeForm(value); form.feeId = '1'
expect(buildFinance('fees/release-estimate', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
value.fees[0].walletStatus = 'SETTLED'; expect(() => buildFinance('fees/release-estimate', form, value, [], base, '')).toThrow('有效预估')
})
it('rejects a persisted selection that mixes an existing reference and a new amount', () => {
const selection = { feeId: '1', value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '免费' } }
expect(() => readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload: { ...base, internationalFreight: selection, duty: selection, domesticFreight: selection, otherDetermination: 'NOT_APPLICABLE', otherFees: [] } }))).toThrow('ambiguous')
})
})
@@ -0,0 +1,113 @@
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementFeeSelection } from './api/procurementCommands'
import { cents, decimal } from './procurementPresentation'
export const financeCommands = new Set<ProcurementCommand>(['charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional'])
export type ChargePurpose = 'INTERNATIONAL_FREIGHT' | 'DUTY' | 'DOMESTIC_FREIGHT' | 'OTHER'
export interface ChargeDraft { purpose: ChargePurpose; code: string; name: string; mode: 'NEW' | 'EXISTING'; feeId: string; determination: '' | 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string; reason: string }
export interface AdditionalDraft { name: string; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string }
export interface FinanceForm { additional: AdditionalDraft; fixed: ChargeDraft[]; otherDetermination: 'NOT_APPLICABLE' | 'ITEMIZED'; others: ChargeDraft[]; feeId: string; targetOriginalAmount: string; refundAmount: string }
const titles: Record<ChargePurpose, string> = { INTERNATIONAL_FREIGHT: '实际国际运费', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用' }
export function chargeDraft(purpose: ChargePurpose, fee?: ProcurementInternalFee, code = ''): ChargeDraft { return { purpose, code: fee?.feeItemCode || code || purpose, name: fee?.feeItemName || titles[purpose], mode: fee ? 'EXISTING' : 'NEW', feeId: fee?.id || '', determination: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '', reason: '' } }
export function financeForm(detail: PlatformProcurement): FinanceForm { const fees = detail.fees.filter(row => row.active); const others = fees.filter(row => row.purpose === 'OTHER').map(row => chargeDraft('OTHER', row)); return { additional: { name: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '' }, fixed: (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => chargeDraft(purpose, fees.find(row => row.purpose === purpose))), otherDetermination: others.length ? 'ITEMIZED' : 'NOT_APPLICABLE', others, feeId: '', targetOriginalAmount: '', refundAmount: '' } }
export function feeRetryPermission(fee: ProcurementInternalFee) { return `platform:overseas-business:purchase:${fee.confirmationOrigin === 'FINANCIAL' ? 'finance-adjust' : fee.purpose === 'SAMPLE_PAYMENT' ? 'buy' : fee.purpose === 'INTERNATIONAL_ESTIMATE' ? 'estimate' : ['ADJUSTMENT', 'REFUND'].includes(fee.purpose) ? 'finance-adjust' : 'charge'}` }
export function linked(detail: PlatformProcurement, fee: ProcurementInternalFee) { return detail.customerView.fees.some(row => row.feeNo === fee.feeNo && row.sourceLinked) }
function feeAmount(fee: ProcurementInternalFee) { const amount = cents(fee.amount); if (amount === null || amount < 0n || fee.currency !== 'CNY') throw new Error('原费用人民币金额缺失,请先核对'); return amount }
export function feeChain(detail: PlatformProcurement, selected: ProcurementInternalFee) {
const root = selected.purpose === 'ADJUSTMENT' ? detail.fees.find(row => row.id === selected.parentFeeId) : selected
if (!root || ['REFUND', 'ADJUSTMENT', 'INTERNATIONAL_ESTIMATE'].includes(root.purpose) || !root.active || !linked(detail, root)) throw new Error('原实际费用及来源关联尚未完成')
const originals = new Set([root.id, ...detail.fees.filter(row => row.purpose === 'ADJUSTMENT' && row.parentFeeId === root.id).map(row => row.id)])
const chain = detail.fees.filter(row => originals.has(row.id) || (row.purpose === 'REFUND' && !!row.parentFeeId && originals.has(row.parentFeeId)))
if (chain.some(row => row.active && !linked(detail, row))) throw new Error('该费用链仍有未完成的调整或退款,请先完成或终止原请求')
return chain
}
export function refundAvailable(detail: PlatformProcurement, fee: ProcurementInternalFee) {
feeChain(detail, fee)
if (fee.walletStatus !== 'SETTLED' || !fee.walletLedgerId || fee.purpose === 'INTERNATIONAL_ESTIMATE') throw new Error('退款必须关联已成功实扣的实际费用')
return feeAmount(fee) - detail.fees.filter(row => row.purpose === 'REFUND' && row.parentFeeId === fee.id && row.walletStatus === 'REFUNDED').reduce((sum, row) => sum + feeAmount(row), 0n)
}
export function replacementCandidates(detail: PlatformProcurement) {
return detail.fees.filter(fee => !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) && !fee.sourceFeeId && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
&& !detail.fees.some(other => other.purpose === fee.purpose && other.feeItemCode === fee.feeItemCode && (other.active || other.feeVersion > fee.feeVersion)))
}
function frozenTarget(fee: ProcurementInternalFee, amount: string) {
const original = cents(amount); const rate = /^(\d+)(?:\.(\d{1,12}))?$/.exec(String(fee.exchangeRate))
if (original === null || original < 0n || !rate) throw new Error('请填写正确原币总额,并核对原汇率快照')
const denominator = 10n ** BigInt((rate[2] || '').length); const numerator = BigInt(rate[1] + (rate[2] || ''))
if (numerator <= 0n) throw new Error('原汇率快照无效')
const target = (original * numerator * 2n + denominator) / (2n * denominator)
if (target > 99999999999999999999n) throw new Error('折算人民币金额超出支持范围')
return target
}
export function replacementPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
if (!replacementCandidates(detail).some(row => row.id === fee.id)) throw new Error('须选择最新、已终止或释放且未实扣的实际费用')
const target = frozenTarget(fee, amount)
if (target > 9999999999999999n || cents(amount)! > 9999999999999999n) throw new Error('折算人民币或原币金额超出支持范围')
if (fee.purpose === 'SAMPLE_PAYMENT') {
const budget = detail.customerView.budget; const limit = cents(budget.totalAmount)
if (limit === null) throw new Error('客户预算尚未明确')
if ((budget.currency === fee.originalCurrency && cents(amount)! > limit) || (budget.currency === 'CNY' && target > limit)) throw new Error('替代货款超过客户授权预算,请先完成预算核验')
}
return { target: decimal(target), net: '0.00', difference: decimal(target) }
}
export function targetPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
if (['ADJUSTMENT', 'REFUND', 'INTERNATIONAL_ESTIMATE'].includes(fee.purpose)) throw new Error('目标更正须选择原实际费用')
const chain = feeChain(detail, fee); const target = frozenTarget(fee, amount)
const net = chain.reduce((sum, row) => row.walletStatus === 'SETTLED' ? sum + feeAmount(row) : row.walletStatus === 'REFUNDED' ? sum - feeAmount(row) : sum, 0n)
if (target - net > 9999999999999999n) throw new Error('追加人民币金额超出支持范围')
return { target: decimal(target), net: decimal(net), difference: decimal(target - net) }
}
function amount(value: string, positive = false) { const parsed = cents(value.trim()); if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(value.trim()) || parsed === null || (positive ? parsed <= 0n : parsed < 0n)) throw new Error(positive ? '请填写正额金额,最多两位小数' : '请明确填写非负金额,最多两位小数'); return value.trim() }
function select(draft: ChargeDraft, detail: PlatformProcurement, files: ProcurementFile[]): ProcurementFeeSelection {
if (draft.mode === 'NEW' && detail.operations.some(row => row.action === 'CONFIRM_CHARGES')) throw new Error('首次核费后只可引用已有费用,新发生或替代费用请使用财务操作')
if (draft.mode === 'EXISTING') {
if (!detail.fees.some(row => row.id === draft.feeId && row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}引用的费用不是本单该用途的有效版本`)
return { feeId: draft.feeId }
}
if (detail.fees.some(row => row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}已有有效费用,请引用原版本或先按权限更正`)
if (!draft.determination) throw new Error(`${draft.name}尚未明确核定,未知不能视为零元`)
const value = amount(draft.amount, draft.determination === 'POSITIVE')
if (draft.determination !== 'POSITIVE' && cents(value) !== 0n) throw new Error('零元或不适用必须明确记为 0.00')
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.businessDate) || !Number.isFinite(Date.parse(draft.businessDate))) throw new Error(`请选择${draft.name}的业务日期`)
if (!draft.reason.trim() || draft.reason.trim().length > 500) throw new Error(`请填写${draft.name}的核定说明,最多 500 字`)
if (draft.determination === 'POSITIVE' && !files.some(file => file.fileId === draft.voucherFileId && file.category === 'VOUCHER')) throw new Error(`${draft.name}须关联本单费用凭证`)
return { value: { determination: draft.determination, amount: value, currency: draft.currency, businessDate: draft.businessDate, ...(draft.voucherFileId ? { voucherFileId: draft.voucherFileId } : {}), reason: draft.reason.trim() } }
}
export function buildFinance(command: ProcurementCommand, form: FinanceForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
if (command === 'fees/additional') {
if (!detail.purchaseFacts.purchased_at) throw new Error('独立新增费用须关联已发生购买的订单')
const row = form.additional; const value = amount(row.amount, true)
if (!row.name.trim() || row.name.trim().length > 128) throw new Error('请填写独立费用名称,最多 128 字')
const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.businessDate) || !Number.isFinite(Date.parse(row.businessDate)) || row.businessDate > today) throw new Error('请选择已发生费用的真实业务日期')
if (!files.some(file => file.fileId === row.voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单新增费用凭证')
if ((base.reason?.length || 0) > 500) throw new Error('新增费用说明最多 500 字')
return { ...base, name: row.name.trim(), amount: value, currency: row.currency, businessDate: row.businessDate, voucherFileId: row.voucherFileId }
}
if (command === 'charges/confirm') {
if (!detail.purchaseFacts.domestic_received_at) throw new Error('须完成国内收货后确认费用')
const fixed = (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => { const rows = form.fixed.filter(row => row.purpose === purpose); if (rows.length !== 1) throw new Error('固定费用项目不完整'); return select(rows[0], detail, files) })
if ((form.otherDetermination === 'NOT_APPLICABLE') !== (form.others.length === 0)) throw new Error('其他费用适用时须逐项核定,不适用时不保留收费行')
const codes = new Set<string>()
const otherFees = form.others.map(row => { if (!/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || !row.name.trim() || row.name.trim().length > 128) throw new Error('其他费用名称必填且项目不可重复'); codes.add(row.code); return { code: row.code, name: row.name.trim(), selection: select(row, detail, files) } })
const selected = new Set([...fixed, ...otherFees.map(row => row.selection)].map(row => row.feeId).filter(Boolean))
if (detail.fees.some(row => row.active && ['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT', 'OTHER'].includes(row.purpose) && !selected.has(row.id))) throw new Error('不能通过省略有效费用隐藏原收费')
return { ...base, internationalFreight: fixed[0], duty: fixed[1], domesticFreight: fixed[2], otherDetermination: form.otherDetermination, otherFees }
}
const fee = detail.fees.find(row => row.id === form.feeId); if (!fee) throw new Error('请选择本单费用')
const common = { ...base, feeId: fee.id }
if (command === 'fees/retry' || command === 'fees/terminate') {
if (!fee.active || !fee.walletStatus.endsWith('_FAILED')) throw new Error('仅能处理资金结果已明确失败的有效费用,未知结果不能新建重试')
return common
}
if (command === 'fees/release-estimate') {
if (!fee.active || fee.purpose !== 'INTERNATIONAL_ESTIMATE' || !['FROZEN', 'ZERO'].includes(fee.walletStatus)) throw new Error('仅能释放已冻结或已确认零元的有效预估')
return common
}
if (!files.some(file => file.fileId === voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单财务更正凭证')
if ((base.reason?.length || 0) > 500) throw new Error('财务更正说明最多 500 字')
if (command === 'fees/replace-failed') { const targetOriginalAmount = amount(form.targetOriginalAmount); replacementPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
if (command === 'fees/correct-target') { const targetOriginalAmount = amount(form.targetOriginalAmount); targetPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
const refund = amount(form.refundAmount, true)
if (cents(refund)! > refundAvailable(detail, fee)) throw new Error('退款超过该原扣款的剩余可退金额')
return { ...common, amount: refund, voucherFileId }
}
@@ -0,0 +1,132 @@
import { isAxiosError } from 'axios'
import { ApiResponseError } from '@/shared/api/http'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
const commands = new Set(['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 function commandKey() {
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
if (raw === null) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
const payload = value.payload
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
const body = payload as Record<string, unknown>
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
if (value.command === 'review') {
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
} else if (!nonempty('reason')) throw new Error('missing pending reason')
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
}
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
}
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
}
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
if (value.command === 'dispositions') {
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
}
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
if (value.command === 'packing') {
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
const names = new Set<string>()
for (const item of body.cartons) {
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
names.add(item.cartonNo)
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
}
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
if (!source || typeof source !== 'object') return true
const row = source as Record<string, unknown>
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
})) throw new Error('invalid packing source')
}
}
if (value.command === 'labels') {
const receiver = body.receiver
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
const cartonIds = new Set<string>()
for (const row of body.labels) {
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
cartonIds.add(row.cartonId)
}
}
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
}
if (value.command === 'domestic-repack') {
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
}
}
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
if (!positiveId('feeId')) throw new Error('invalid original fee')
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
}
if (value.command === 'charges/confirm') {
const selection = (input: unknown) => {
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
const item = input as Record<string, unknown>
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
const value = item.value as Record<string, unknown>
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
}
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
const codes = new Set<string>()
for (const row of body.otherFees) {
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
codes.add(row.code); selection(row.selection)
}
}
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
if (value.command === 'exception-close') {
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
const sources = new Set<string>()
for (const row of body.resolutions) {
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
}
}
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
return value as PendingProcurement
}
export function definitelyRejected(error: unknown) {
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
const code = error instanceof ApiResponseError ? error.code : ''
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { buildPacking, buildPhysical, physicalForm, shippedCartons } from './procurementPhysicalForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
function detail(): PlatformProcurement {
return {
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [], internalRemark: null,
}
}
describe('procurement physical request validation', () => {
it('builds multi-carton allocations with exact dimensions, retaining independent source references', () => {
const value = detail(); const form = physicalForm(value)
const payload = buildPhysical('packing', form, value, base, '', '')
expect(payload.cartons?.[1].sources).toEqual([{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }])
expect(payload.cartons?.[0].grossWeightKg).toBe('1.234')
expect(readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toEqual({ command: 'packing', payload })
form.cartons[0].sources[0].quantity = 1
expect(payload.cartons?.[0].sources[0].quantity).toBe(5)
})
it('rejects repeated boxes, duplicate same-box sources, missing sources and cross-box over-allocation', () => {
const value = detail(); let boxes = physicalForm(value).cartons
boxes[1].cartonNo = 'C1'; expect(() => buildPacking(boxes, value)).toThrow('箱号')
boxes = physicalForm(value).cartons; boxes[0].sources.push({ receiptId: '11', quantity: 1 }); expect(() => buildPacking(boxes, value)).toThrow('同箱来源不可重复')
boxes = physicalForm(value).cartons; boxes[0].sources = []; expect(() => buildPacking(boxes, value)).toThrow('每箱必须')
boxes = physicalForm(value).cartons; boxes[0].sources[0].quantity = 6; expect(() => buildPacking(boxes, value)).toThrow('超过实收 PR-11')
})
it('rejects fractional pieces, inactive receipts and excessive measurement precision', () => {
const value = detail(); let boxes = physicalForm(value).cartons
boxes[0].sources[0].quantity = 1.5; expect(() => buildPacking(boxes, value)).toThrow('整数')
boxes = physicalForm(value).cartons; boxes[0].lengthCm = '10.001'; expect(() => buildPacking(boxes, value)).toThrow('最多 2 位')
boxes = physicalForm(value).cartons; boxes[0].grossWeightKg = '1.0001'; expect(() => buildPacking(boxes, value)).toThrow('最多 3 位')
value.physical.receipts[0].active = false; expect(() => buildPacking(physicalForm(value).cartons, value)).toThrow('有效海外实收')
})
it('requires domestic receipts to use an actually shipped original carton and excludes unshipped boxes', () => {
const value = detail(); const form = physicalForm(); form.leg = 'DOMESTIC'; form.receivedAt = '2020-01-04T10:00:00'; form.goodQuantity = 3
expect(shippedCartons(value).map(row => row.cartonNo)).toEqual(['C1'])
form.sourceCartonId = '32'; expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('已国际交寄')
form.sourceCartonId = '31'; const payload = buildPhysical('domestic-receipts', form, value, base, '', '')
expect(payload).toMatchObject({ leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0 }); expect(payload).not.toHaveProperty('warehouseId')
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-receipts', payload }))).not.toBeNull()
value.physical.receipts.push({ id: '13', receiptNo: 'PR-13', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 4, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-04T10:00:00', active: true })
expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('超过来源件数')
})
it('preserves the fixed overseas warehouse, rejects future receipts and keeps extras separate', () => {
const value = detail(); value.physical.receipts = []; const form = physicalForm(); form.goodQuantity = 8; form.damagedQuantity = 2; form.extraQuantity = 3; form.receivedAt = '2020-01-02T10:00:00'
expect(() => buildPhysical('overseas-receipts', form, value, base, 'other', '')).toThrow('已冻结')
expect(buildPhysical('overseas-receipts', form, value, base, '20', '')).toMatchObject({ goodQuantity: 8, damagedQuantity: 2, extraQuantity: 3, warehouseId: '20' })
form.receivedAt = '2999-01-01T10:00:00'; expect(() => buildPhysical('overseas-receipts', form, value, base, '20', '')).toThrow('真实实收时间')
})
it('binds a damaged disposition to its receipt and prevents repeat disposal', () => {
const value = detail(); const form = physicalForm(); form.kind = 'DAMAGED'; form.receiptId = '11'; form.quantity = 1
const payload = buildPhysical('dispositions', form, value, base, '', '901')
expect(payload).toMatchObject({ kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', receiptId: '11', quantity: 1, voucherFileId: '901' })
expect(readPendingProcurement(JSON.stringify({ command: 'dispositions', payload }))).not.toBeNull()
value.physical.dispositions.push({ id: '71', leg: 'OVERSEAS', source_carton_id: null, receipt_id: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucher_file_id: '901', reason: '已处置', active: true, created_at: '' })
expect(() => buildPhysical('dispositions', form, value, base, '', '901')).toThrow('未处理差异')
})
it('binds shortage to the purchase instead of fabricating a receipt', () => {
const value = detail(); value.physical.receipts.pop(); const form = physicalForm(); form.kind = 'SHORTAGE'; form.quantity = 3; form.receiptId = 'stale'
const payload = buildPhysical('dispositions', form, value, base, '', '901')
expect(payload).toMatchObject({ kind: 'SHORTAGE', decision: 'ACCEPT_SHORTAGE', quantity: 3 }); expect(payload).not.toHaveProperty('receiptId')
})
it('corrects an original receipt without counting it twice or moving its origin', () => {
const value = detail(); const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00'
const payload = buildPhysical('receipts/correct', form, value, base, '20', '901')
expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId')
expect(readPendingProcurement(JSON.stringify({ command: 'receipts/correct', payload }))).not.toBeNull()
})
it('confirms the exact draft version and rejects malformed persisted packing before replay', () => {
const value = detail(); const form = physicalForm(value)
expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 })
value.physical.packingStatus = 'CONFIRMED'; expect(() => buildPhysical('packing/confirm', form, value, base, '', '')).toThrow('装箱草稿')
const payload = { ...base, cartons: [{ cartonNo: 'C1', lengthCm: '1', widthCm: '1', heightCm: '1', grossWeightKg: '1', sources: [{ receiptId: '11', quantity: 0 }] }] }
expect(() => readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toThrow('packing source')
})
})
@@ -0,0 +1,76 @@
import type { PlatformProcurement } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
export type Leg = 'OVERSEAS' | 'DOMESTIC'
export const physicalCommands = new Set<ProcurementCommand>(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke'])
export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] }
export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] }
export function newCarton(): PackingDraft { return { cartonNo: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '', sources: [{ receiptId: '', quantity: undefined }] } }
export function physicalForm(detail?: PlatformProcurement): PhysicalForm { return { leg: 'OVERSEAS', sourceCartonId: '', receiptId: '', dispositionId: '', goodQuantity: 0, damagedQuantity: 0, extraQuantity: 0, receivedAt: '', kind: 'SHORTAGE', quantity: undefined, cartons: detail?.physical.cartons.length ? detail.physical.cartons.map(row => ({ cartonNo: row.cartonNo, lengthCm: String(row.lengthCm), widthCm: String(row.widthCm), heightCm: String(row.heightCm), grossWeightKg: String(row.grossWeightKg), sources: row.sources.map(source => ({ ...source })) })) : [newCarton()] } }
export function shippedCartons(detail: PlatformProcurement) { const numbers = new Set(detail.customerView.shipments.filter(row => row.leg === 'OVERSEAS').map(row => row.cartonNo)); return detail.physical.cartons.filter(row => numbers.has(row.cartonNo)) }
export function whole(value: number | undefined, label: string, min = 0): number { if (value === undefined || !Number.isSafeInteger(value) || value < min) throw new Error(`${label}必须为${min ? '正' : '非负'}整数`); return value }
export function dimension(value: string, label: string, scale: number, digits: number) { if (!new RegExp(`^\\d{1,${digits}}(?:\\.\\d{1,${scale}})?$`).test(value.trim()) || Number(value) <= 0) throw new Error(`${label}必须为正数,最多 ${scale} 位小数`); return value.trim() }
export function buildPacking(cartons: PackingDraft[], detail: PlatformProcurement): ProcurementPackingCarton[] {
if (!cartons.length) throw new Error('请至少建立一个装货箱')
const used = new Map<string, number>(); const names = new Set<string>()
return cartons.map(carton => {
const cartonNo = carton.cartonNo.trim(); if (!cartonNo || cartonNo.length > 64 || names.has(cartonNo)) throw new Error('箱号必填、不可重复且不超过 64 个字符'); names.add(cartonNo)
if (!carton.sources.length) throw new Error('每箱必须分配实收来源')
const inCarton = new Set<string>()
const sources = carton.sources.map(source => {
const receipt = detail.physical.receipts.find(row => row.id === source.receiptId && row.active && row.leg === 'OVERSEAS')
if (!receipt || inCarton.has(source.receiptId)) throw new Error('请选择有效海外实收来源,同箱来源不可重复')
inCarton.add(source.receiptId)
const quantity = whole(source.quantity, '装箱件数', 1); const total = (used.get(source.receiptId) || 0) + quantity
if (!Number.isSafeInteger(total) || total > receipt.goodQuantity) throw new Error(`装箱件数超过实收 ${receipt.receiptNo} 的合格数量`)
used.set(source.receiptId, total); return { receiptId: source.receiptId, quantity }
})
return { cartonNo, lengthCm: dimension(carton.lengthCm, '长', 2, 10), widthCm: dimension(carton.widthCm, '宽', 2, 10), heightCm: dimension(carton.heightCm, '高', 2, 10), grossWeightKg: dimension(carton.grossWeightKg, '毛重', 3, 9), sources }
})
}
export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status)
if (closed) throw new Error('订单已结束,不能改写实物记录')
if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) }
if (command === 'packing/confirm') {
if (detail.physical.packingStatus !== 'DRAFT' || !detail.physical.packingVersion) throw new Error('请先保存当前装箱草稿')
return { ...base, packingVersion: detail.physical.packingVersion }
}
if (command === 'packing/withdraw') {
if (detail.physical.packingStatus !== 'CONFIRMED' || detail.purchaseFacts.overseas_shipped_at) throw new Error('仅允许撤回尚未交寄的已确认清单')
return base
}
if (command === 'dispositions/revoke') {
if (!detail.physical.dispositions.some(row => row.id === form.dispositionId && row.active)) throw new Error('请选择有效差异处置')
return { ...base, dispositionId: form.dispositionId, voucherFileId }
}
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
const correction = command === 'receipts/correct'
const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined
if (correction && !old) throw new Error('请选择有效原实收记录')
const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg)
const sourceCartonId = old?.sourceCartonId || form.sourceCartonId
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
if (command === 'dispositions') {
const quantity = whole(form.quantity, '处置件数', 1)
const source = leg === 'DOMESTIC' ? sourceCartonId : null
const receipt = detail.physical.receipts.find(row => row.id === form.receiptId && row.active && row.leg === leg && row.sourceCartonId === source)
const records = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source)
const resolved = detail.physical.dispositions.filter(row => row.active && row.leg === leg && row.source_carton_id === source && row.kind === form.kind && (form.kind === 'SHORTAGE' || row.receipt_id === form.receiptId)).reduce((sum, row) => sum + row.quantity, 0)
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === source)?.quantity
const available = form.kind === 'SHORTAGE' ? Number(expected) - records.reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0) - resolved : receipt ? (form.kind === 'DAMAGED' ? receipt.damagedQuantity : receipt.extraQuantity) - resolved : -1
if (!Number.isSafeInteger(available) || quantity > available) throw new Error('处置件数超过当前来源的未处理差异')
const decisions = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' } as const
return { ...base, leg, ...(leg === 'DOMESTIC' ? { sourceCartonId } : {}), ...(form.kind === 'SHORTAGE' ? {} : { receiptId: form.receiptId }), kind: form.kind, quantity, decision: decisions[form.kind], voucherFileId }
}
const goodQuantity = whole(form.goodQuantity, '合格件数'), damagedQuantity = whole(form.damagedQuantity, '破损件数'), extraQuantity = whole(form.extraQuantity, '多货件数')
if (!Number.isSafeInteger(goodQuantity + damagedQuantity + extraQuantity) || goodQuantity + damagedQuantity + extraQuantity <= 0) throw new Error('本次实收总件数必须大于零')
if (!form.receivedAt || !Number.isFinite(Date.parse(form.receivedAt)) || Date.parse(form.receivedAt) > Date.now()) throw new Error('请填写不晚于当前时间的真实实收时间')
const origin = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_at : detail.purchaseFacts.overseas_shipped_at
if (!origin || Date.parse(form.receivedAt) < Date.parse(origin)) throw new Error('实收时间不能早于购买或国际交寄时间')
const source = leg === 'DOMESTIC' ? sourceCartonId : null
const counted = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source && row.id !== old?.id).reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0)
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === sourceCartonId)?.quantity
if (expected == null || goodQuantity + damagedQuantity + counted > expected) throw new Error('合格及破损实收超过来源件数,多到货物请填入多货')
if (leg === 'OVERSEAS' && (!warehouseId || (detail.purchaseFacts.actual_warehouse_id && detail.purchaseFacts.actual_warehouse_id !== warehouseId))) throw new Error('实际收货仓必须与本单已冻结的仓库一致')
return { ...base, leg, ...(leg === 'OVERSEAS' ? { warehouseId } : { sourceCartonId }), goodQuantity, damagedQuantity, extraQuantity, receivedAt: form.receivedAt, ...(correction ? { receiptId: form.receiptId, voucherFileId } : {}) }
}
@@ -0,0 +1,65 @@
import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
}
export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
if (status === 'CANCELLED') return 'info'
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
return 'warning'
}
export function cents(value: Amount | null | undefined): bigint | null {
if (value === null || value === undefined) return null
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim())
if (!match) return null
const result = BigInt(match[2]) * 100n + BigInt((match[3] || '').padEnd(2, '0'))
return match[1] ? -result : result
}
export function decimal(value: bigint) {
const absolute = value < 0n ? -value : value
return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`
}
export function money(value: Amount | null | undefined, currency: string | null | undefined = 'CNY') {
const amount = cents(value)
return amount === null ? '—' : `${currency || '—'} ${decimal(amount)}`
}
export function dateTime(value: string | null | undefined) { return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '—' }
export function measurement(value: Amount | null | undefined, scale: number) {
if (value === null || value === undefined) return '—'
const parts = /^(\d+)(?:\.(\d+))?$/.exec(String(value))
if (!parts || (parts[2] || '').length > scale) return '待核对'
return `${parts[1]}.${(parts[2] || '').padEnd(scale, '0')}`
}
export function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
export function productHref(value: string | null | undefined) {
if (!value) return undefined
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined } catch { return undefined }
}
export function feeStatus(fee: ProcurementFee) {
if (fee.purpose === 'INTERNATIONAL_ESTIMATE') {
const label: Record<string, string> = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' }
if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus]
}
const labels: Record<string, string> = {
FREEZE_PENDING: '冻结处理中', FREEZE_FAILED: '冻结未成功', FROZEN: '等待扣款', SETTLE_PENDING: '扣款处理中', SETTLE_ACTUAL_PENDING: '实际费用结算中',
SETTLE_FAILED: '扣款未成功', SETTLE_ACTUAL_FAILED: '实际费用未结清', SETTLED: '已扣款', RELEASE_PENDING: '释放处理中', RELEASE_FAILED: '释放未成功',
RELEASED: '已释放冻结', REFUND_PENDING: '退款处理中', REFUND_FAILED: '退款未成功', REFUNDED: '已退款', ZERO: '已确认零元', NOT_APPLICABLE: '不适用', TERMINATED: '已终止',
}
return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定'
}
export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' {
if (fee.walletStatus?.endsWith('_FAILED')) return 'danger'
if (['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE', 'RELEASED'].includes(fee.walletStatus || '')) return 'success'
if (fee.walletStatus === 'TERMINATED') return 'info'
return 'warning'
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { buildTransport, transportForm } from './procurementTransportForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement, ProcurementFile, ProcurementLabelSet } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'transport-one', reason: '逐箱核对' }
const files: ProcurementFile[] = [{ id: '51', fileId: '501', category: 'LABEL', fileName: 'labels.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '52', fileId: '502', category: 'EXCEPTION', fileName: 'customer.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '53', fileId: '503', category: 'RECEIPT', fileName: 'repack.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' }]
function labels(): ProcurementLabelSet { return { id: '71', version: 2, manifestId: '61', cargoHash: 'cargo', sender: { name: '海外发件人', phone: '13800000000', address: 'US warehouse' }, receiver: { name: '国内收货人', phone: '13800000001', address: '上海接收地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'carrier', trackingNo: 'TRACK-1', appliedAt: '2020-01-02T12:00:00' }, { cartonId: '32', fileId: '501', page: 2, carrier: 'carrier', trackingNo: 'TRACK-2', appliedAt: '2020-01-02T13:00:00' }] } }
function detail(): PlatformProcurement {
const cargo = [{ cartonId: '31', cartonNo: 'BOX-1', quantity: 6, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 6 }] }, { cartonId: '32', cartonNo: 'BOX-2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.111', packagingRecordId: null, sources: [{ receiptId: '12', quantity: 4 }] }]
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳客户地址', contactName: '客户收件人', contactPhone: '13800000002', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'WAIT_OVERSEAS_SHIPMENT', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, purchaseFacts: { overseas_received_at: '2020-01-02T10:00:00', domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 3, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: cargo, domesticCargo: [{ ...cargo[0], quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987', packagingRecordId: '81', sources: [{ receiptId: '21', quantity: 5 }] }], overseasLabels: labels(), domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [...files], reviewHistory: [], operations: [], actions: [{ key: 'SHIP_OVERSEAS', label: '海外交寄', permission: 'ship-overseas', allowed: true, reason: null }, { key: 'SHIP_CUSTOMER', label: '寄客户', permission: 'ship-customer', allowed: true, reason: null }], internalRemark: null }
}
describe('procurement transport form rules', () => {
it('binds the full batch to files, pages and the exact packing version', () => {
const value = detail(); const form = transportForm(value)
const payload = buildTransport('labels', form, value, files, base, '')
expect(payload).toMatchObject({ leg: 'OVERSEAS', packingVersion: 3, labels: [{ cartonId: '31', fileId: '501', page: 1 }, { cartonId: '32', fileId: '501', page: 2 }] })
expect(payload).not.toHaveProperty('sender')
expect(readPendingProcurement(JSON.stringify({ command: 'labels', payload }))).toEqual({ command: 'labels', payload })
form.labels[0].trackingNo = 'changed-after-submit'; expect(payload.labels?.[0].trackingNo).toBe('TRACK-1')
})
it('rejects missing or duplicate cartons, wrong-purpose files and invalid image pages', () => {
const value = detail(); let form = transportForm(value); form.labels.pop(); expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
form = transportForm(value); form.labels[1].cartonId = '31'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
form = transportForm(value); form.labels[0].fileId = '502'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('每箱选择本单')
form = transportForm(value); expect(() => buildTransport('labels', form, value, [{ ...files[0], contentType: 'image/png' }], base, '')).toThrow('第 1 页')
})
it('uses domestic good quantities and replacement measurements, requiring proof for recipient changes', () => {
const value = detail(); const form = transportForm(value, 'DOMESTIC'); form.labels[0] = { cartonId: '31', fileId: '501', page: 1, carrier: 'SF', trackingNo: 'SF-1' }
expect(form.receiver).toEqual({ name: '客户收件人', phone: '13800000002', address: '深圳客户地址' })
expect(value.transport.domesticCargo[0]).toMatchObject({ quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987' })
expect(buildTransport('labels', form, value, files, base, '')).not.toHaveProperty('addressVoucherFileId')
form.receiver.address = '客户确认的新地址'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('客户确认凭证')
form.addressVoucherFileId = '502'; expect(buildTransport('labels', form, value, files, base, '').addressVoucherFileId).toBe('502')
})
it('requires confirmed packing, domestic receipt completion and an unshipped leg for label changes', () => {
const value = detail(); const form = transportForm(value); value.physical.packingStatus = 'DRAFT'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('确认装箱')
value.physical.packingStatus = 'CONFIRMED'; value.purchaseFacts.overseas_shipped_at = '2020-01-03T10:00:00'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('本段已交寄')
value.purchaseFacts.domestic_received_at = null; expect(() => buildTransport('labels', transportForm(value, 'DOMESTIC'), value, files, base, '')).toThrow('国内收货完成')
})
it('makes zero estimate explicit without sending actual-charge fields', () => {
const value = detail(); const form = transportForm(value); form.businessDate = '2020-01-02'
expect(() => buildTransport('international-estimate', form, value, files, base, '')).toThrow('明确填写')
form.amount = '0.00'; const payload = buildTransport('international-estimate', form, value, files, base, '')
expect(payload).toEqual({ ...base, amount: '0.00', businessDate: '2020-01-02', labelVersion: 2 })
expect(readPendingProcurement(JSON.stringify({ command: 'international-estimate', payload }))).not.toBeNull()
})
it('allows partial application only for current unapplied labels with a real time', () => {
const value = detail(); value.transport.overseasLabels!.labels[0].appliedAt = null; const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-02T11:00:00'
expect(buildTransport('labels/apply', form, value, files, base, '')).toEqual({ ...base, labelVersion: 2, cartonIds: ['31'], appliedAt: '2020-01-02T11:00:00' })
form.cartonIds = ['32']; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('尚未确认贴单')
form.cartonIds = ['31']; form.occurredAt = '2020-01-01T10:00:00'; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('早于上一节点')
})
it('ships the entire authoritative batch and checks label time and server readiness', () => {
const value = detail(); const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-03T10:00:00'
const payload = buildTransport('overseas-shipment', form, value, files, base, '')
expect(payload).toEqual({ ...base, leg: 'OVERSEAS', labelVersion: 2, cartonIds: ['31', '32'], shippedAt: '2020-01-03T10:00:00' })
expect(readPendingProcurement(JSON.stringify({ command: 'overseas-shipment', payload }))).not.toBeNull()
form.occurredAt = '2020-01-02T12:30:00'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('早于上一节点')
value.actions[0].allowed = false; value.actions[0].reason = '预估冻结未完成'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('预估冻结未完成')
})
it('keeps repacking one-to-one and never changes quantity or the original source', () => {
const value = detail(); const form = transportForm(value, 'DOMESTIC'); Object.assign(form, { cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123' })
const payload = buildTransport('domestic-repack', form, value, files, base, '503')
expect(payload).toEqual({ ...base, cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123', voucherFileId: '503' })
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-repack', payload }))).not.toBeNull()
form.cartonId = 'other'; expect(() => buildTransport('domestic-repack', form, value, files, base, '503')).toThrow('可寄原箱')
})
it('rejects corrupted recovered shipment identity before any request', () => {
expect(() => readPendingProcurement(JSON.stringify({ command: 'customer-shipment', payload: { ...base, labelVersion: 1, cartonIds: ['31', '31'], leg: 'DOMESTIC', shippedAt: '2020-01-03T10:00:00' } }))).toThrow('transport cartons')
})
})
@@ -0,0 +1,75 @@
import type { PlatformProcurement, ProcurementAddress, ProcurementFile } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
import { dimension, whole, type Leg } from './procurementPhysicalForm'
export const transportCommands = new Set<ProcurementCommand>(['labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment'])
export interface TransportForm {
leg: Leg; receiver: ProcurementAddress; addressVoucherFileId: string
labels: { cartonId: string; fileId: string; page: number | undefined; carrier: string; trackingNo: string }[]
amount: string; businessDate: string; occurredAt: string; cartonIds: string[]; cartonId: string
lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string
}
export function transportCargo(detail: PlatformProcurement, leg: Leg) { return leg === 'OVERSEAS' ? detail.transport.overseasCargo : detail.transport.domesticCargo }
export function transportForm(detail: PlatformProcurement, leg: Leg = detail.purchaseFacts.overseas_shipped_at ? 'DOMESTIC' : 'OVERSEAS'): TransportForm {
const previous = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
const order = detail.customerView.order
const receiver = previous?.receiver ?? (leg === 'DOMESTIC' ? { name: order.contactName, phone: order.contactPhone, address: order.receivingAddress } : { name: '', phone: '', address: '' })
return { leg, receiver: { ...receiver }, addressVoucherFileId: '', labels: transportCargo(detail, leg).map(box => {
const old = previous?.labels.find(row => row.cartonId === box.cartonId)
return { cartonId: box.cartonId, fileId: old?.fileId ?? '', page: old?.page ?? 1, carrier: old?.carrier ?? '', trackingNo: old?.trackingNo ?? '' }
}), amount: '', businessDate: '', occurredAt: '', cartonIds: [], cartonId: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '' }
}
function requireText(value: string, max: number, label: string) { const result = value.trim(); if (!result || result.length > max) throw new Error(`请填写${label},最多 ${max} 个字符`); return result }
function realTime(value: string, earliest?: string | null) {
const time = Date.parse(value)
if (!value || !Number.isFinite(time) || time > Date.now()) throw new Error('请填写不晚于当前时间的实际操作时间')
if (earliest && time < Date.parse(earliest)) throw new Error('实际操作时间不能早于上一节点')
return value
}
export function buildTransport(command: ProcurementCommand, form: TransportForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
const leg = command === 'customer-shipment' || command === 'domestic-repack' ? 'DOMESTIC' : command === 'labels' ? form.leg : 'OVERSEAS'
const cargo = transportCargo(detail, leg)
if (!cargo.length) throw new Error('当前运输段没有可寄货物')
const labels = leg === 'OVERSEAS' ? detail.transport.overseasLabels : detail.transport.domesticLabels
if (command === 'labels') {
if (detail.physical.packingStatus !== 'CONFIRMED' || !detail.physical.packingVersion) throw new Error('请先确认装箱清单')
if (leg === 'DOMESTIC' && !detail.purchaseFacts.domestic_received_at) throw new Error('国内收货完成后才能制作寄客户面单')
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) throw new Error('本段已交寄,不能修改原面单')
const receiver = { name: requireText(form.receiver.name, 128, '收件人'), phone: form.receiver.phone.trim(), address: requireText(form.receiver.address, 1000, '完整收件地址') }
if (!/^[+0-9 ()-]{5,64}$/.test(receiver.phone)) throw new Error('请填写有效快递联系电话')
const original = detail.customerView.order
const changed = leg === 'DOMESTIC' && (receiver.name !== original.contactName || receiver.phone !== original.contactPhone || receiver.address !== original.receivingAddress)
if (changed && !files.some(file => file.fileId === form.addressVoucherFileId && file.category === 'EXCEPTION')) throw new Error('最终收件信息已变更,请关联本单客户确认凭证')
const unique = new Set(form.labels.map(row => row.cartonId))
if (unique.size !== cargo.length || form.labels.length !== cargo.length || cargo.some(box => !unique.has(box.cartonId))) throw new Error('面单必须完整覆盖本批全部原箱,不可重复或跨单')
const rows = form.labels.map(row => {
const file = files.find(file => file.fileId === row.fileId && file.category === 'LABEL')
if (!file || !['application/pdf', 'image/png', 'image/jpeg'].includes(file.contentType || '')) throw new Error('请为每箱选择本单 PDF、JPG 或 PNG 面单')
const page = whole(row.page, '面单页码', 1)
if (file.contentType !== 'application/pdf' && page !== 1) throw new Error('图片面单只能对应第 1 页')
return { cartonId: row.cartonId, fileId: row.fileId, page, carrier: requireText(row.carrier, 128, '承运商'), trackingNo: requireText(row.trackingNo, 128, '运单号') }
})
return { ...base, leg, packingVersion: detail.physical.packingVersion, receiver, ...(changed ? { addressVoucherFileId: form.addressVoucherFileId } : {}), labels: rows }
}
if (command === 'domestic-repack') {
if (!cargo.some(box => box.cartonId === form.cartonId)) throw new Error('请选择本单国内可寄原箱')
if (!files.some(file => file.fileId === voucherFileId && ['RECEIPT', 'EXCEPTION'].includes(file.category))) throw new Error('请关联本单换包装凭证')
return { ...base, cartonId: form.cartonId, lengthCm: dimension(form.lengthCm, '长', 2, 10), widthCm: dimension(form.widthCm, '宽', 2, 10), heightCm: dimension(form.heightCm, '高', 2, 10), grossWeightKg: dimension(form.grossWeightKg, '毛重', 3, 9), voucherFileId }
}
if (!labels) throw new Error('请先保存本段面单')
if (command === 'international-estimate') {
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写人民币预估金额,包括零元')
if (!/^\d{4}-\d{2}-\d{2}$/.test(form.businessDate) || !Number.isFinite(Date.parse(form.businessDate))) throw new Error('请选择预估业务日期')
if ((base.reason?.length || 0) > 500) throw new Error('预估核对说明最多 500 个字符')
return { ...base, labelVersion: labels.version, amount: form.amount.trim(), businessDate: form.businessDate }
}
if (command === 'labels/apply') {
if (!form.cartonIds.length || new Set(form.cartonIds).size !== form.cartonIds.length || form.cartonIds.some(id => !labels.labels.some(row => row.cartonId === id && !row.appliedAt))) throw new Error('请选择当前面单中尚未确认贴单的箱')
return { ...base, labelVersion: labels.version, cartonIds: [...form.cartonIds], appliedAt: realTime(form.occurredAt, detail.purchaseFacts.overseas_received_at) }
}
const action = detail.actions.find(row => row.key === (leg === 'OVERSEAS' ? 'SHIP_OVERSEAS' : 'SHIP_CUSTOMER'))
if (!action?.allowed) throw new Error(action?.reason || '当前不满足交寄条件')
if (labels.labels.length !== cargo.length || cargo.some(box => !labels.labels.some(label => label.cartonId === box.cartonId))) throw new Error('当前面单与可寄原箱不一致,请重新核对')
const latestApplied = labels.labels.reduce<string | null>((latest, row) => row.appliedAt && (!latest || row.appliedAt > latest) ? row.appliedAt : latest, null)
if (leg === 'OVERSEAS' && labels.labels.some(row => !row.appliedAt)) throw new Error('本批全部箱须完成贴单')
return { ...base, leg, labelVersion: labels.version, cartonIds: cargo.map(box => box.cartonId), shippedAt: realTime(form.occurredAt, leg === 'OVERSEAS' ? latestApplied : detail.purchaseFacts.domestic_received_at) }
}
@@ -0,0 +1,119 @@
export type Amount = number | string
export interface ProcurementSummary {
orderNo: string
purchaseCategory: string
productName: string
productSpecification: string | null
purchaseQuantity: number
budgetUnitPrice: Amount | null
currency: string | null
status: string
version: number
createdAt: string
updatedAt: string
}
export interface ProcurementOrder extends ProcurementSummary {
id: string
customerId: string
customerName: string | null
productUrl: string | null
receivingAddress: string
contactName: string
contactPhone: string
estimatedArrivalAt: string | null
plannedWarehouseName: string | null
actualWarehouseCode: string | null
actualWarehouseName: string | null
}
export interface ProcurementFee {
feeNo: string
purpose: string
name: string
version: number | null
active: boolean
determination: string
amount: Amount
currency: string
originalAmount: Amount | null
originalCurrency: string | null
exchangeRate: Amount | null
exchangeRateMonth: string | null
businessDate: string | null
parentFeeNo: string | null
walletStatus: string | null
sourceLinked: boolean
}
export interface ProcurementBox {
cartonNo: string
quantity: number
lengthCm: Amount
widthCm: Amount
heightCm: Amount
grossWeightKg: Amount
}
export interface ProcurementShipment {
shipmentNo: string
leg: 'OVERSEAS' | 'DOMESTIC'
shippedAt: string
cartonNo: string
quantity: number
carrier: string
trackingNo: string
}
export interface ProcurementDetail {
order: ProcurementOrder
budget: { totalAmount: Amount | null; currency: string; authorizedAt: string | null }
purchased: boolean
purchaseConfirmed: boolean
purchaseNeedsAttention: boolean
progress: { key: string; title: string; completedAt: string | null }[]
boxes: ProcurementBox[]
shipments: ProcurementShipment[]
fees: ProcurementFee[]
exceptionSummaries: string[]
}
export interface ProcurementAction { key: string; label: string; permission: string; allowed: boolean; reason: string | null }
export type ProcurementFileCategory = 'PURCHASE' | 'BUDGET' | 'LABEL' | 'EXCEPTION' | 'RECEIPT' | 'VOUCHER'
export interface ProcurementFile { id: string; fileId: string; category: ProcurementFileCategory; fileName: string; contentType: string | null; sizeBytes: number; createdAt: string }
export interface ProcurementReceipt { id: string; receiptNo: string; leg: 'OVERSEAS' | 'DOMESTIC'; sourceCartonId: string | null; goodQuantity: number; damagedQuantity: number; extraQuantity: number; receivedAt: string; active: boolean }
export interface ProcurementDisposition { id: string; leg: 'OVERSEAS' | 'DOMESTIC'; source_carton_id: string | null; receipt_id: string | null; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number; decision: string; voucher_file_id: string; reason: string; active: boolean | number; created_at: string }
export interface ProcurementCarton extends ProcurementBox { id: string; sources: { receiptId: string; quantity: number }[] }
export interface ProcurementCargo extends ProcurementBox { cartonId: string; packagingRecordId: string | null; sources: { receiptId: string; quantity: number }[] }
export interface ProcurementAddress { name: string; phone: string; address: string }
export interface ProcurementLabelSet { id: string; version: number; manifestId: string; cargoHash: string; sender: ProcurementAddress; receiver: ProcurementAddress; labels: { cartonId: string; fileId: string; page: number; carrier: string; trackingNo: string; appliedAt: string | null }[] }
export interface ProcurementInternalFee {
id: string; feeNo: string; purpose: string; confirmationOrigin: 'BUSINESS' | 'FINANCIAL'; feeItemCode: string; feeItemName: string; feeVersion: number; active: boolean; determination: string
amount: Amount; currency: string; originalAmount: Amount; originalCurrency: string; exchangeRate: Amount; exchangeRateMonth: string; businessDate: string
parentFeeId: string | null; sourceFeeId: string | null; voucherFileId: string | null; voucherFileName: string | null
walletStatus: string; walletLedgerId: string | null; walletErrorCode: string | null; walletErrorMessage: string | null; remark: string | null
}
export interface ProcurementFeeCorrection {
correctionNo: string; rootFeeNo: string; targetOriginalAmount: Amount; originalCurrency: string; exchangeRate: Amount; exchangeRateMonth: string
targetAmount: Amount; previousNetAmount: Amount; differenceAmount: Amount; feeNos: string[]
}
export type ProcurementRemainingKind = 'PURCHASE_REMAINDER' | 'TRANSIT_REMAINDER' | 'GOOD' | 'DAMAGED' | 'EXTRA'
export interface ProcurementRemaining { kind: ProcurementRemainingKind; sourceId: string | null; businessNo: string; quantity: number }
export interface ProcurementControlNote {
id: string; note_no: string; target_type: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; target_id: string | null; blocking: boolean | number
reason: string; client_summary: string | null; created_at: string; resolved_at: string | null; resolution_reason: string | null
voucher_file_id: string; resolution_voucher_id: string | null
original_value_json: { name?: string; code?: string } | null
corrected_value_json: { warehouseName?: string; warehouseCode?: string } | null
}
export interface ProcurementClosure { id: string; closure_no: string; financial_conclusion: string; reason: string; client_summary: string; created_at: string }
export interface ProcurementClosureItem { id: string; closure_id: string; source_kind: ProcurementRemainingKind; source_id: string | null; quantity: number; decision: string; destination: string; voucher_file_id: string }
export interface PlatformProcurement {
customerView: ProcurementDetail
purchaseFacts: { overseas_received_at?: string | null; domestic_received_at?: string | null; actual_warehouse_id?: string | null; overseas_shipped_at?: string | null; customer_shipped_at?: string | null; purchased_at?: string | null; purchased_product_name?: string | null; purchased_specification?: string | null; purchased_quantity?: number | null; purchase_amount?: Amount | null; purchase_currency?: string | null; purchase_issue?: string | null; purchase_confirmed_at?: string | null }
physical: { dispositions: ProcurementDisposition[]; receipts: ProcurementReceipt[]; packingVersion: number | null; packingStatus: string | null; cartons: ProcurementCarton[] }
transport: { overseasCargo: ProcurementCargo[]; domesticCargo: ProcurementCargo[]; overseasLabels: ProcurementLabelSet | null; domesticLabels: ProcurementLabelSet | null; shipments: { id: string; shipmentNo: string; leg: string; shippedAt: string }[] }
controls: { notes: ProcurementControlNote[]; remaining: ProcurementRemaining[]; closures: ProcurementClosure[]; closureItems: ProcurementClosureItem[] }
files: ProcurementFile[]
fees: ProcurementInternalFee[]
feeCorrections: ProcurementFeeCorrection[]
reviewHistory: import('./types').OverseasBusinessHistory[]
operations: { action: string; operator_id: string; reason: string; detail_json: unknown; created_at: string }[]
actions: ProcurementAction[]
internalRemark: string | null
}
+6 -26
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'PENDING_OUTBOUND' | 'PARTIALLY_OUTBOUND' | 'OUTBOUNDED'
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'WAIT_PURCHASE' | 'WAIT_OVERSEAS_RECEIPT' | 'OVERSEAS_RECEIVING' | 'WAIT_PACKING' | 'WAIT_LABELS' | 'WAIT_OVERSEAS_SHIPMENT' | 'INTERNATIONAL_TRANSIT' | 'DOMESTIC_RECEIVING' | 'WAIT_CHARGES' | 'WAIT_CUSTOMER_SHIPMENT' | 'SHIPPED_TO_CUSTOMER' | 'REJECTED' | 'CANCELLED' | 'EXCEPTION_CLOSED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -16,36 +16,16 @@ export interface OverseasPurchaseSummary {
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
}
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
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: OverseasPurchaseFile[]; fees: OverseasPurchaseFee[]
}
export interface OverseasPurchaseFile { id: string; category: 'RECEIPT' | 'VOUCHER'; fileId: string; fileName: string; contentType: string | null; sizeBytes: number; createdAt: string }
export interface OverseasPurchaseFee { id: string; feeNo: string; feeItemCode: string; feeItemName: string; amount: string; currency: string; voucherFileId: string; voucherFileName: string; remark: string | null; walletStatus: string; walletErrorMessage: string | null; createdAt: string }
export interface OverseasPurchaseOutboundDetail { id: string; quantity: number; carrier: string | null; trackingNo: string | null; shippedAt: string | null; remark: string | null }
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
export interface UpdateOverseasPurchaseRequest {
version: number; purchaseCategory?: string; productName?: string; productSpecification?: string | null
productUrl?: string | null; inboundQuantity?: number; purchaseWarehouseId?: string | null
domesticCarrier?: string | null; domesticTrackingNo?: string | null; overseasCarrier?: string | null
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
outboundDetails?: OverseasPurchaseOutboundDetail[]
export interface UpdateOverseasPurchaseRequest { version: number; remark?: string | null }
/** Fields consumed by the standalone remark editor from the aggregate platform detail. */
export interface OverseasPurchaseEditorDetail {
customerView: { order: { orderNo: string; customerId: string; productName: string; purchaseQuantity: number; version: number } }
internalRemark: string | null
}
export interface ReviewOverseasPurchaseRequest {
version: number
decision: 'APPROVE' | 'REJECT'
idempotencyKey: string
remark?: string
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export interface LocalTransportQuery {
+2 -2
View File
@@ -43,7 +43,7 @@ const overseasConsumers = [
const overseasBusinessConsumers = [
'overseas-business/pages/PurchaseOrdersView.vue', 'overseas-business/pages/PurchaseOrderDetailView.vue',
'overseas-business/pages/PurchaseOrderEditView.vue', 'overseas-business/pages/TransportOrdersView.vue',
'overseas-business/pages/TransportOrderFormView.vue',
'overseas-business/pages/TransportOrderFormView.vue', 'overseas-business/components/ProcurementNodeActions.vue', 'overseas-business/components/ProcurementPhysicalFields.vue', 'overseas-business/components/ProcurementTransportFields.vue', 'overseas-business/components/ProcurementFinanceFields.vue', 'overseas-business/components/ProcurementChargeFields.vue', 'overseas-business/components/ProcurementControlFields.vue',
]
const customerServiceConsumers = [
'overseas-warehouse/pages/ExceptionOrdersView.vue', 'overseas-warehouse/pages/CustomerVisitsView.vue',
@@ -402,7 +402,7 @@ describe('approved Admin density ownership', () => {
for (const node of nodes) {
if (node.type !== 1) continue
const classes = classesOf(node)
if (node.tag === 'DataTable' || node.tag === 'LoadingListDetailDrawer' || classes.some(name => ['density-action', 'density-actions', 'density-tabs', 'density-fields', 'density-tab-icon', 'density-back'].includes(name))) {
if (node.tag === 'DataTable' || node.tag === 'LoadingListDetailDrawer' || classes.some(name => ['density-action', 'density-actions', 'density-tabs', 'density-fields', 'density-table', 'density-tab-icon', 'density-back'].includes(name))) {
expect(insideDensity, `${file}: ${classes.join(' ')} lacks its density ancestor`).toBe(true)
checked += 1
}