fix(procurement): align correction actions with shipment state

This commit is contained in:
2026-10-10 19:52:33 +08:00
parent e124fbcf68
commit d7f2d09b88
7 changed files with 162 additions and 18 deletions
@@ -41,6 +41,39 @@ beforeEach(() => {
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('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02', active: true }]
const wrapper = render(value); const dialog = await open(wrapper)
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' })
expect(selector.props('modelValue')).toBe('control-notes')
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === 'receipts/correct')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('订单已结束') })
expect(options.find(option => option.props('value') === 'control-notes')!.props('disabled')).toBe(false)
expect(options.find(option => option.props('value') === 'actual-warehouse/correct')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', 'receipts/correct'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
await confirm(wrapper)
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('disables overseas receipt candidates after international shipment without blocking domestic correction', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'DOMESTIC_RECEIVING'
value.purchaseFacts.overseas_shipped_at = '2020-01-02'
const receipt = { id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS' as const, sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-01', active: true }
value.physical.receipts = [receipt, { ...receipt, id: '12', receiptNo: 'PR-12', leg: 'DOMESTIC', sourceCartonId: '31' }]
const wrapper = render(value); const dialog = await open(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'receipts/correct')
await flushPromises()
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '待更正实收')!.findComponent({ name: 'ElSelect' })
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === '11')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('本段已实际交寄') })
expect(options.find(option => option.props('value') === '12')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', '12'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
const value = fixture('FINANCE_ADJUST', false)
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: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
@@ -181,7 +214,7 @@ describe('procurement purchase node operations', () => {
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
const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2; value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
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, '破损样品已经处理,客户已确认')
@@ -21,7 +21,7 @@ import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '
import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical } from '../procurementPhysicalForm'
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore()
@@ -49,6 +49,17 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
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(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
if (reason) return reason
if (command.value === 'dispositions') return dispositionReason(props.detail, physical.value.leg)
const row = command.value === 'receipts/correct'
? props.detail.physical.receipts.find(item => item.id === physical.value.receiptId)
: command.value === 'dispositions/revoke' ? props.detail.physical.dispositions.find(item => item.id === physical.value.dispositionId) : null
return row ? receiptCorrectionReason(props.detail, row.leg) : null
})
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))
@@ -77,7 +88,7 @@ async function loadWarehouses() {
}
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail)
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; 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')
@@ -150,6 +161,7 @@ async function submit() {
if (!pending.value) {
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
try {
if (managementBlock.value) throw new Error(managementBlock.value)
const request = { command: command.value, payload: build() }
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
sessionStorage.setItem(scope, serialized); pending.value = request
@@ -201,10 +213,11 @@ async function upload(event: Event) {
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><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="retryPending">确认上次提交结果</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 || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<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="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<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 === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.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" />
<template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template>
@@ -3,7 +3,7 @@ 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'
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, 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)))))
@@ -22,12 +22,12 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
<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="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></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" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></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>
<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" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></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">
@@ -206,6 +206,19 @@ describe('overseas business mounted pages', () => {
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const cells = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')[0]!.findAll('td')
expect(cells[0]!.classes()).toContain('el-table-fixed-column--left')
for (const [index, value] of [record.orderNo, record.customerName, record.purchaseWarehouseName, record.purchaseCategory, record.productName, record.productSpecification].entries()) {
expect(cells[index + 1]!.text()).toBe(value)
expect(cells[index + 1]!.classes()).not.toContain('data-table__single-line-cell')
expect(cells[index + 1]!.find('.cell').classes()).not.toContain('el-tooltip')
}
})
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 })) })
@@ -30,12 +30,12 @@ const fields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ 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: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ 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) },
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildPacking, buildPhysical, physicalForm, shippedCartons } from './procurementPhysicalForm'
import { buildPacking, buildPhysical, physicalForm, shippedCartons, managementReason, receiptCorrectionReason } from './procurementPhysicalForm'
import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
@@ -65,11 +65,52 @@ describe('procurement physical request validation', () => {
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 value = detail(); value.purchaseFacts.overseas_shipped_at = null; 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('blocks shipped receipt corrections while preserving domestic corrections before customer shipment', () => {
const value = detail()
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('本段已实际交寄')
expect(receiptCorrectionReason(value, 'DOMESTIC')).toBeNull()
value.purchaseFacts.customer_shipped_at = '2020-01-05T10:00:00'
expect(receiptCorrectionReason(value, 'DOMESTIC')).toContain('本段已实际交寄')
value.purchaseFacts.overseas_shipped_at = null
value.physical.packingStatus = 'CONFIRMED'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('先撤回')
value.physical.packingStatus = 'DRAFT'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toBeNull()
})
it.each(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])('blocks physical changes for %s but leaves evidence and warehouse correction available', status => {
const value = detail(); value.customerView.order.status = status
expect(managementReason('receipts/correct', value)).toContain('订单已结束')
expect(managementReason('dispositions', value)).toContain('订单已结束')
expect(managementReason('packing/withdraw', value)).toContain('订单已结束')
expect(managementReason('control-notes', value)).toBeNull()
expect(managementReason('actual-warehouse/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('订单已结束')
})
it('requires the correct physical source and unfinished receipt before recording differences', () => {
const value = detail(); value.purchaseFacts = {}
expect(managementReason('dispositions', value)).toContain('交寄来源')
value.purchaseFacts.purchased_at = '2020-01-01'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.overseas_received_at = '2020-01-02'
expect(managementReason('dispositions', value)).not.toBeNull()
value.purchaseFacts.overseas_shipped_at = '2020-01-03'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.domestic_received_at = '2020-01-04'
expect(managementReason('dispositions', value)).toContain('本段收货已完成')
})
it('rejects a shipped receipt even when another leg still offers a correctable receipt', () => {
const value = detail()
value.physical.receipts.push({ ...value.physical.receipts[0]!, id: '13', leg: 'DOMESTIC', sourceCartonId: '31' })
expect(managementReason('receipts/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('本段已实际交寄')
})
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 })
@@ -1,6 +1,43 @@
import type { PlatformProcurement } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
export type Leg = 'OVERSEAS' | 'DOMESTIC'
const closedStatuses = new Set(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])
export function receiptCorrectionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,只能追加事实说明,不能更正实收数量'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) return '本段已实际交寄,只能追加更正说明,不能改写数量'
if (leg === 'OVERSEAS' && detail.physical.packingStatus === 'CONFIRMED') return '须先撤回尚未交寄的确认清单,再更正海外实收'
return null
}
export function dispositionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能修改差异处置'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_received_at' : 'domestic_received_at']) return '本段收货已完成,须先受控更正,不能直接改差异结果'
if (leg === 'OVERSEAS' && !detail.purchaseFacts.purchased_at) return '尚无本单真实购买来源'
if (leg === 'DOMESTIC' && !detail.purchaseFacts.overseas_shipped_at) return '尚无本单国际交寄来源'
return null
}
export const managementOptions: { value: ProcurementCommand; label: string }[] = [
{ value: 'dispositions', label: '记录差异处置' }, { value: 'receipts/correct', label: '更正原实收数量' },
{ value: 'dispositions/revoke', label: '撤销原差异处置' }, { value: 'packing/withdraw', label: '撤回未交寄的确认清单' },
{ value: 'control-notes', label: '追加事实说明或阻断' }, { value: 'control-notes/resolve', label: '解除已处理的阻断' },
{ value: 'actual-warehouse/correct', label: '实际仓受控更正' },
]
export function managementReason(command: ProcurementCommand, detail: PlatformProcurement): string | null {
if (command === 'receipts/correct' || command === 'dispositions/revoke') {
const records = command === 'receipts/correct' ? detail.physical.receipts : detail.physical.dispositions
const active = records.filter(row => row.active)
if (!active.length) return command === 'receipts/correct' ? '暂无有效实收记录' : '暂无有效差异处置'
return active.some(row => !receiptCorrectionReason(detail, row.leg)) ? null : receiptCorrectionReason(detail, active[0]!.leg)
}
if (command === 'dispositions') return dispositionReason(detail, 'OVERSEAS') && dispositionReason(detail, 'DOMESTIC')
if (command === 'packing/withdraw') {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能撤回装箱事实'
if (detail.purchaseFacts.overseas_shipped_at) return '国际段已交寄,不能撤回装箱事实'
if (detail.physical.packingStatus !== 'CONFIRMED') return '暂无已确认清单可撤回'
}
if (command === 'control-notes/resolve' && !detail.controls.notes.some(row => row.blocking && !row.resolved_at)) return '暂无待处理阻断'
if (command === 'actual-warehouse/correct' && !detail.purchaseFacts.actual_warehouse_id) return '尚无实际收货仓,请修改计划仓'
return null
}
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[] }
@@ -28,6 +65,8 @@ export function buildPacking(cartons: PackingDraft[], detail: PlatformProcuremen
})
}
export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
const blocked = managementReason(command, detail)
if (blocked) throw new Error(blocked)
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) }
@@ -40,7 +79,10 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
return base
}
if (command === 'dispositions/revoke') {
if (!detail.physical.dispositions.some(row => row.id === form.dispositionId && row.active)) throw new Error('请选择有效差异处置')
const row = detail.physical.dispositions.find(row => row.id === form.dispositionId && row.active)
if (!row) throw new Error('请选择有效差异处置')
const reason = receiptCorrectionReason(detail, row.leg)
if (reason) throw new Error(reason)
return { ...base, dispositionId: form.dispositionId, voucherFileId }
}
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
@@ -48,6 +90,8 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
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 reason = correction ? receiptCorrectionReason(detail, leg) : command === 'dispositions' ? dispositionReason(detail, leg) : null
if (reason) throw new Error(reason)
const sourceCartonId = old?.sourceCartonId || form.sourceCartonId
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
if (command === 'dispositions') {