diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 6c67f278..76612995 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -50,13 +50,13 @@ describe('adminNavigation', () => { { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] }, { title: '客户服务', children: ['异常订单'] }, - { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) expect(visible).toHaveLength(13) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(59) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9fc1a023..3114bb97 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -146,7 +146,6 @@ export const adminNavigation: AdminNavigationNode[] = [ icon: PrototypeOverseasBusinessIcon, children: [ { key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' }, - { key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' }, { key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' }, { key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' }, { key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] }, @@ -166,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '财务管理', icon: PrototypeFinanceIcon, children: [ + { key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' }, { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' }, { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' }, { key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' }, diff --git a/src/modules/overseas-business/AGENTS.md b/src/modules/overseas-business/AGENTS.md index 1df78962..b6c3619a 100644 --- a/src/modules/overseas-business/AGENTS.md +++ b/src/modules/overseas-business/AGENTS.md @@ -22,7 +22,7 @@ ## 关键边界 -- 采购样品履约是一条独立链路:下单、包装、面单、交寄、国内核费、寄客户;改节点或费用前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`,不套用普通头程或尾程流程。 +- 采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md` 的七状态流程执行:审核、分批收货、客户确认、分批出库及签收扣采购库存;费用复用海外费用窗口,默认余额支付,不恢复旧两段样品节点。 - 本土卡车与散板是海外本地运输业务,区别于国内订舱拖车与客户排车;车型、司机与车队主数据归 `overseas-master-data`,本模块只消费。 - 采购费用入海外费用池后由 `finance/` 记账,本模块不判定收付状态,不重复写入费用行。 - 列表与表单的客户、仓库、供应商候选一律取服务端结果,不从本地或名称推算;长整型 ID 按字符串透传。 diff --git a/src/modules/overseas-business/api/procurementWorkflow.test.ts b/src/modules/overseas-business/api/procurementWorkflow.test.ts new file mode 100644 index 00000000..3aa190af --- /dev/null +++ b/src/modules/overseas-business/api/procurementWorkflow.test.ts @@ -0,0 +1,14 @@ +import { beforeEach, expect, it, vi } from 'vitest' +const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })) +vi.mock('@/shared/api/http', async importOriginal => ({ ...await importOriginal(), http: api })) +import { getProcurementWorkflow, submitProcurementWorkflow } from './procurementWorkflow' +beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }); api.post.mockResolvedValue({ code: '200', data: { orderNo: 'OP/1' } }) }) +it('scopes workflow and every batch operation to the exact order and customer', async () => { + await getProcurementWorkflow('OP/1', '1001') + expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/workflow', { params: { customerId: '1001' } }) + for (const action of ['receipts', 'request-confirmation', 'outbounds', 'outbounds/9007199254740993/dispatch', 'outbounds/9007199254740993/complete']) { + const command = { version: 3, idempotencyKey: 'retry-same-key' } + await submitProcurementWorkflow('OP/1', '1001', action, command) + expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${action}`, command, { params: { customerId: '1001' } }) + } +}) diff --git a/src/modules/overseas-business/api/procurementWorkflow.ts b/src/modules/overseas-business/api/procurementWorkflow.ts new file mode 100644 index 00000000..b1922c0d --- /dev/null +++ b/src/modules/overseas-business/api/procurementWorkflow.ts @@ -0,0 +1,16 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface ProcurementWorkflowReceipt { id: string; quantity: number; fileIds: string[]; receivedAt: string } +export interface ProcurementOutbound { id: string; outboundNo: string; quantity: number; trackingNo: string; status: string; fileIds: string[]; createdAt: string; completedAt: string | null } +export interface ProcurementWorkflow { + orderNo: string; version: number; status: string; receivedQuantity: number; stockQuantity: number; reservedQuantity: number; availableQuantity: number + receipts: ProcurementWorkflowReceipt[]; outbounds: ProcurementOutbound[] +} +export interface WorkflowCommand { version: number; idempotencyKey: string; quantity?: number; trackingNo?: string; fileIds?: string[] } +const base = '/platform/v1/last-leg/overseas-procurements' +export async function getProcurementWorkflow(orderNo: string, customerId: string) { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/${encodeURIComponent(orderNo)}/workflow`, { params: { customerId } })) +} +export async function submitProcurementWorkflow(orderNo: string, customerId: string, action: string, payload: WorkflowCommand) { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/${encodeURIComponent(orderNo)}/${action}`, payload, { params: { customerId } })) +} diff --git a/src/modules/overseas-business/components/BusinessOrderFees.test.ts b/src/modules/overseas-business/components/BusinessOrderFees.test.ts index 02bcffb8..f5e74aea 100644 --- a/src/modules/overseas-business/components/BusinessOrderFees.test.ts +++ b/src/modules/overseas-business/components/BusinessOrderFees.test.ts @@ -68,6 +68,18 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('单据费用操作', () => { + it('uses the same fee dialog for procurement with balance deduction selected by default', async () => { + vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) + const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' } + const wrapper = mount(BusinessOrderFees, { props: { business: procurement }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) + await flushPromises(); await click(wrapper, '新增费用') + const dialog = activeDialog(wrapper) + const form = dialog.getComponent({ name: 'ElForm' }).props('model') + expect(form.settlementMode).toBe('WALLET') + Object.assign(form, { feeItemCode: 'HANDLING', amount: 12.34 }) + await click(dialog, '保存') + expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', businessId: procurement.businessId, customerId: procurement.customerId, settlementMode: 'WALLET', amount: 12.34 }), expect.any(String)) + }) it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) diff --git a/src/modules/overseas-business/components/OverseasFeeEditor.vue b/src/modules/overseas-business/components/OverseasFeeEditor.vue index 89dcd4b1..e6ac2735 100644 --- a/src/modules/overseas-business/components/OverseasFeeEditor.vue +++ b/src/modules/overseas-business/components/OverseasFeeEditor.vue @@ -70,7 +70,7 @@ watch(open, async value => { openedBusinessKey = businessKey() error.value = null const fee = props.fee - Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() }) + Object.assign(form, { businessType: props.business?.businessType || fee?.businessType || 'WAREHOUSE_TRANSFER', businessId: props.business?.businessId || fee?.businessId || '', feeItemCode: fee?.feeItemCode || '', feeRemark: fee?.feeRemark || '', settlementMode: fee?.direction === 'RECEIVABLE' && (fee?.settlementMode === 'BILL' || fee?.settlementMode === 'WALLET') ? fee.settlementMode : props.business?.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : '', currency: fee?.originalCurrency || 'USD', amount: Number(fee?.originalAmount || 0), businessDate: fee?.businessDate || today() }) options.value = fee ? [{ businessId: fee.businessId || '', businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, orderId: fee.orderId, warehouseId: fee.warehouseId }] : [] captureBaseline() formRef.value?.clearValidate() @@ -79,7 +79,7 @@ watch(open, async value => { const sourceTypes: Record = { LASTLEG_ORDER: 'OVERSEAS_ORDER_FEE', WAREHOUSE_TRANSFER: 'OVERSEAS_WAREHOUSE_FEE', RETURN_ORDER: 'OVERSEAS_WAREHOUSE_FEE', DISCARD_ORDER: 'OVERSEAS_WAREHOUSE_FEE', RELABEL_ORDER: 'OVERSEAS_RELABEL_FEE', US_TRUCK: 'OVERSEAS_TRUCK_FEE', - LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE', + LOCAL_LOOSEBOARD: 'OVERSEAS_LOOSEBOARD_FEE', OVERSEAS_PROCUREMENT: 'OVERSEAS_PROCUREMENT_FEE', OVERSEAS_INBOUND: 'OVERSEAS_INBOUND_FEE', DELIVERY_ORDER: 'OVERSEAS_DELIVERY_FEE', } const selectableTypes = Object.keys(sourceTypes) @@ -130,7 +130,7 @@ async function save() { - +
此单已有相同费用,确认本次为新增作业后可继续添加。

{{ sameProjectFeeSummary(row) }}

diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.test.ts b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts index 3818269d..3594b3bf 100644 --- a/src/modules/overseas-business/components/ProcurementNodeActions.test.ts +++ b/src/modules/overseas-business/components/ProcurementNodeActions.test.ts @@ -29,8 +29,8 @@ function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'r return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } }) } async function open(wrapper: ReturnType) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) } -async function explain(wrapper: ReturnType, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) } -async function confirm(wrapper: ReturnType) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() } +async function explain(wrapper: ReturnType, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => ['操作说明', '审核说明', '拒绝原因'].includes(row.props('label')))!.find('textarea').setValue(value) } +async function confirm(wrapper: ReturnType) { const dialog = wrapper.findComponent(AppDialog); const approve = dialog.findAll('button').find(button => button.text() === '通过'); if (approve) await approve.trigger('click'); else dialog.vm.$emit('confirm'); await flushPromises() } enableAutoUnmount(afterEach) afterEach(() => vi.unstubAllGlobals()) beforeEach(() => { @@ -40,40 +40,43 @@ 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('switches to domestic warehouses, clears old selection and preserves editable direction after failure', async () => { - vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(async direction => direction === 'DOMESTIC_TO_OVERSEAS' - ? [{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }] - : [{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外采购仓', countryCode: 'US' }]) - const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper) - const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! - field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001') - field('采购方向').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises() - expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith('DOMESTIC_TO_OVERSEAS') - expect(field('采购仓').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('') - await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled() - field('采购仓').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '30001') - vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('connection lost')) - await confirm(wrapper) - expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ procurementDirection: 'DOMESTIC_TO_OVERSEAS', purchaseWarehouseId: '30001' })) - const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3] - expect(sessionStorage.getItem(storageKey)).toBeNull() - expect(field('采购方向').findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DOMESTIC_TO_OVERSEAS') - await confirm(wrapper) - const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3] - expect(next.procurementDirection).toBe('DOMESTIC_TO_OVERSEAS') - expect(next.purchaseWarehouseId).toBe('30001') - expect(next.idempotencyKey).not.toBe(original.idempotencyKey) + it.each(['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'] as const)('locks review direction and requests the matching warehouse catalog: %s', async direction => { + const value = fixture(); value.customerView.order.procurementDirection = direction + const wrapper = render(value); const dialog = await open(wrapper) + const fields = dialog.findAllComponents({ name: 'ElFormItem' }) + const directionField = fields.find(item => item.props('label') === '采购方向')! + expect(directionField.findComponent({ name: 'ElSelect' }).exists()).toBe(false) + expect(directionField.text()).toContain(direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购,发往海外' : '海外采购,发往国内') + expect(listOverseasPurchaseWarehouseOptions).toHaveBeenLastCalledWith(direction) + expect(fields.some(item => item.props('label') === (direction === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓'))).toBe(true) + expect(fields.some(item => item.props('label') === '审核决定')).toBe(false) + expect(dialog.findAll('button').map(button => button.text())).toEqual(expect.arrayContaining(['取消', '拒绝', '通过'])) + expect(dialog.text()).not.toContain('确认提交') }) - it('ignores late warehouse responses from a previous direction', async () => { - let finishImport!: (rows: Awaited>) => void - vi.mocked(listOverseasPurchaseWarehouseOptions).mockImplementation(direction => direction === 'DOMESTIC_TO_OVERSEAS' - ? Promise.resolve([{ warehouseId: '30001', warehouseCode: 'SZX', warehouseName: '国内采购仓', countryCode: 'CN' }]) - : new Promise(resolve => { finishImport = resolve })) + it('allows approval without a remark after a blank rejection attempt once a warehouse is selected', async () => { const wrapper = render(); const dialog = await open(wrapper) - dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购方向')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DOMESTIC_TO_OVERSEAS'); await flushPromises() - finishImport([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '过期海外候选', countryCode: 'US' }]); await flushPromises() - const warehouse = dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }) - expect(warehouse.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual(['30001']) + await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises() + expect(submitProcurementCommand).not.toHaveBeenCalled() + dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001') + await confirm(wrapper) + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'APPROVE', remark: '', purchaseWarehouseId: '20001' })) + }) + it('requires a nonblank rejection reason and lets cancel close without changing the order', async () => { + const wrapper = render(); const dialog = await open(wrapper) + const reject = () => dialog.findAll('button').find(button => button.text() === '拒绝')! + await reject().trigger('click'); await flushPromises() + expect(submitProcurementCommand).not.toHaveBeenCalled() + expect(dialog.text()).toContain('请填写拒绝原因') + await explain(wrapper, ' '); await reject().trigger('click'); await flushPromises() + expect(submitProcurementCommand).not.toHaveBeenCalled() + await explain(wrapper, '供应商无法提供该商品'); await reject().trigger('click'); await flushPromises() + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT', remark: '供应商无法提供该商品' })) + expect(vi.mocked(submitProcurementCommand).mock.calls[0]![3]).not.toHaveProperty('purchaseWarehouseId') + wrapper.unmount() + const next = render(); const nextDialog = await open(next) + await nextDialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises() + expect(submitProcurementCommand).toHaveBeenCalledTimes(1) + expect(nextDialog.props('modelValue')).toBe(false) }) it('disables terminal physical changes and keeps the management evidence form open', async () => { @@ -264,14 +267,14 @@ describe('procurement purchase node operations', () => { 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.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '采购仓')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper) + dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '海外采购仓')!.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', procurementDirection: 'OVERSEAS_TO_DOMESTIC', 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) + await dialog.findAll('button').find(button => button.text() === '拒绝')!.trigger('click'); await flushPromises() expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' })) expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId') }) diff --git a/src/modules/overseas-business/components/ProcurementNodeActions.vue b/src/modules/overseas-business/components/ProcurementNodeActions.vue index cfe02c8b..09bd1500 100644 --- a/src/modules/overseas-business/components/ProcurementNodeActions.vue +++ b/src/modules/overseas-business/components/ProcurementNodeActions.vue @@ -23,7 +23,7 @@ import ProcurementTransportFields from './ProcurementTransportFields.vue' import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm' import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue' import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm' -import { directionLabel, procurementDirections, regionText, type ProcurementDirection } from '../procurementDirection' +import { directionLabel, regionText } from '../procurementDirection' const props = defineProps<{ detail: PlatformProcurement; customerId: string }>() const emit = defineEmits<{ changed: [] }>() const auth = useAuthStore(); const access = useAccessPreviewStore() @@ -39,8 +39,10 @@ const error = ref(null); const storageError = ref(''); c const editingDraft = ref(false); const correctionDraft = ref(null); const draftError = ref('') const warehouses = ref([]); const warehouseLoading = ref(false); const warehouseError = ref('') const extraFiles = ref([]) -const form = reactive({ procurementDirection: (props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') as ProcurementDirection, decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' }) -const warehouseDirection = computed(() => command.value === 'review' ? form.procurementDirection : props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') +const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' }) +const warehouseDirection = computed(() => props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC') +const purchaseWarehouseLabel = computed(() => warehouseDirection.value === 'DOMESTIC_TO_OVERSEAS' ? '国内采购仓' : '海外采购仓') +const rejecting = ref(false) const t = (text: string) => regionText(text, warehouseDirection.value) const control = ref(controlForm(props.detail)) const finance = ref(financeForm(props.detail)) @@ -69,7 +71,7 @@ const isFinance = computed(() => !!command.value && financeCommands.has(command. 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 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') 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)) @@ -98,8 +100,8 @@ function open(action: ProcurementAction) { 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, { procurementDirection: props.detail.customerView.order.procurementDirection || 'OVERSEAS_TO_DOMESTIC', 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() + 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: '' }) + rejecting.value = false; opened.value = true; captureBaseline() if (needsWarehouse.value) void loadWarehouses() } function openCorrectionDraft() { @@ -131,9 +133,9 @@ watch(financialCommand, () => { if (editingDraft.value) return; finance.value.fe 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('请填写操作说明') + if (!form.reason.trim() && (command.value !== 'review' || form.decision === 'REJECT')) throw new Error(command.value === 'review' ? '请填写拒绝原因' : '请填写操作说明') 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 (needsWarehouse.value && !(command.value === 'review' && form.decision === 'REJECT') && !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) @@ -141,7 +143,7 @@ function build(): ProcurementCommandBody { 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, procurementDirection: form.procurementDirection, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) } + if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, procurementDirection: warehouseDirection.value, 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') @@ -153,6 +155,12 @@ function build(): ProcurementCommandBody { } return base } +async function review(decision: 'APPROVE' | 'REJECT') { + if (saving.value || uploading.value) return + rejecting.value = decision === 'REJECT' + form.decision = decision + await submit() +} async function submit() { if (editingDraft.value) { saveCorrectionDraft(); return } if (saving.value || uploading.value || storageError.value || !key.value) return @@ -208,7 +216,7 @@ async function upload(event: Event) { - + - + diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts new file mode 100644 index 00000000..8623cf85 --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus from 'element-plus' +import AppDialog from '@/shared/components/AppDialog.vue' +import { useAuthStore } from '@/core/auth/auth' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue' +import type { PlatformProcurement } from '../procurementTypes' +import type { ProcurementWorkflow } from '../api/procurementWorkflow' +import { submitProcurementWorkflow } from '../api/procurementWorkflow' +import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands' +import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness' +vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() })) +vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() })) +vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() })) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) +enableAutoUnmount(afterEach) +const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] }) +const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement +const beforeOutbound = vi.fn<() => Promise>() +function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin() + return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } }) +} +beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(undefined); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) }) +describe('simplified procurement operations', () => { + it('blocks the outbound dialog when fees are missing or not approved and permits retry after approval', async () => { + const wrapper = render('CONFIRMED') + const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')! + beforeOutbound.mockRejectedValueOnce(new Error('请先添加费用明细并审核通过后再出库')) + await button.trigger('click'); await flushPromises() + expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false) + expect(submitProcurementWorkflow).not.toHaveBeenCalled() + beforeOutbound.mockRejectedValueOnce(new Error('费用明细尚未全部审核通过')) + await button.trigger('click'); await flushPromises() + expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false) + await button.trigger('click'); await flushPromises() + expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true) + }) + it('gates receipt and outbound actions with the existing operation permissions', () => { + const receipt = render('RECEIVING', []) + expect(receipt.findAll('button').find(button => button.text() === '登记收货')!.attributes('disabled')).toBeDefined() + expect(receipt.text()).toContain('当前账号未开通采购收货权限') + const outbound = render('CONFIRMED', []) + expect(outbound.findAll('button').find(button => button.text() === '创建出库单')!.attributes('disabled')).toBeDefined() + expect(outbound.text()).toContain('当前账号未开通采购出库及签收权限') + expect(render('CONFIRMED').text()).toContain('创建出库单') + }) + it('requires receipt photos and uploads them before submitting the current batch quantity', async () => { + const wrapper = render(); await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click') + const dialog = wrapper.findComponent(AppDialog) + dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled() + vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '1', fileId: '9007199254740993', category: 'RECEIPT', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' }) + const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element + Object.defineProperty(input, 'files', { configurable: true, value: [new File(['photo'], 'receipt.png', { type: 'image/png' })] }) + input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'receipts', { version: 4, idempotencyKey: expect.any(String), quantity: 1, fileIds: ['9007199254740993'] }) + }) + it('retains the outbound input and idempotency key after a failed submit', async () => { + vi.mocked(submitProcurementWorkflow).mockRejectedValue(new Error('暂时失败')) + const wrapper = render('CONFIRMED'); await wrapper.findAll('button').find(button => button.text() === '创建出库单')!.trigger('click') + await flushPromises() + const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model') + form.trackingNo = 'TRACK-1'; form.quantity = 3 + dialog.vm.$emit('confirm'); await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) + expect(vi.mocked(submitProcurementWorkflow).mock.calls[0]![3]).toEqual(vi.mocked(submitProcurementWorkflow).mock.calls[1]![3]) + expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3) + form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) + }) + it('requires a rejection reason and preserves the current version and customer scope', async () => { + const wrapper = render('PENDING_REVIEW', ['review']) + await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises() + const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog) + form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled() + form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined })) + }) + it('only completes the selected delivering batch after signature evidence is supplied', async () => { + const wrapper = render('DELIVERING') + await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '上传签收凭证')!.trigger('click'); await flushPromises() + const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog) + dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled() + vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '2', fileId: '9007199254740995', category: 'RECEIPT', fileName: 'signed.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }) + const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element + expect(input.getAttribute('accept')).toContain('application/pdf') + Object.defineProperty(input, 'files', { configurable: true, value: [new File(['signed'], 'signed.pdf', { type: 'application/pdf' })] }) + input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises() + expect(uploadProcurementFile).toHaveBeenCalledWith('OP-1', '1001', 'RECEIPT', expect.any(File)) + expect(form.fileIds).toEqual(['9007199254740995']) + dialog.vm.$emit('confirm'); await flushPromises() + expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'outbounds/9007199254740993/complete', { version: 4, idempotencyKey: expect.any(String), fileIds: ['9007199254740995'] }) + }) +}) diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue new file mode 100644 index 00000000..9a3d41ed --- /dev/null +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/modules/overseas-business/components/TransportDemandForm.vue b/src/modules/overseas-business/components/TransportDemandForm.vue index b9d0c36a..418a0187 100644 --- a/src/modules/overseas-business/components/TransportDemandForm.vue +++ b/src/modules/overseas-business/components/TransportDemandForm.vue @@ -31,14 +31,14 @@ defineExpose({ - - - - - - - - + + + + + + + + @@ -58,8 +58,8 @@ defineExpose({
- - + +
diff --git a/src/modules/overseas-business/components/TruckOrderActions.vue b/src/modules/overseas-business/components/TruckOrderActions.vue index f764f684..18aa04e4 100644 --- a/src/modules/overseas-business/components/TruckOrderActions.vue +++ b/src/modules/overseas-business/components/TruckOrderActions.vue @@ -11,6 +11,7 @@ import { executeUsTruckCommand } from '../api/platformOverseasBusiness' import { overseasBusinessPermissions } from '../permissions' import { availableTruckCommands, truckCommandHints, truckCommandLabels, type TruckCommand, type TruckOperation } from '../truckWorkflow' import type { LocalTransportDetail } from '../types' +import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses/api/platformWarehouses' const props = defineProps<{ detail: LocalTransportDetail }>() const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>() @@ -20,7 +21,8 @@ const open = ref(false), saving = ref(false) const active = ref(null) const error = ref(null), validationError = ref('') const pending = ref(null) -const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' }) +const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' }) +const warehouses = ref([]) const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED')) const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage') const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) }) @@ -30,25 +32,33 @@ watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version pending.value = null; open.value = false; active.value = null; error.value = null }) -function start(command: TruckCommand) { +async function start(command: TruckCommand) { if (saving.value || !commands.value.includes(command)) return if (pending.value) { active.value = pending.value.command; open.value = true return } active.value = command; error.value = null; validationError.value = '' - Object.assign(form, { decision: 'APPROVE', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' }) + Object.assign(form, { decision: 'APPROVE', warehouseId: '', actualPalletCount: Math.max(1, props.detail.receivedPalletCount), packageCount: undefined, palletIds: [], remark: '' }) + if (command === 'review' || command === 'arrange') { + try { warehouses.value = (await listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })).records } catch { warehouses.value = [] } + } open.value = true; captureBaseline() } function buildOperation(): TruckOperation | null { if (!active.value) return null const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined } + if ((active.value === 'review' && form.decision === 'APPROVE') || active.value === 'arrange') { + if (!form.warehouseId) { validationError.value = '请选择 A 海外仓'; return null } + } if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) { validationError.value = '请填写拒绝或取消原因'; return null } switch (active.value) { - case 'review': case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } } + case 'review': return { command: active.value, payload: { ...base, decision: form.decision, ...(form.decision === 'APPROVE' ? { warehouseId: form.warehouseId } : {}) } } + case 'arrange': return { command: active.value, payload: { ...base, warehouseId: form.warehouseId } } + case 'review-pallet-overage': return { command: active.value, payload: { ...base, decision: form.decision } } case 'receive-pallets': if (!Number.isSafeInteger(form.actualPalletCount) || form.actualPalletCount < Math.max(1, props.detail.receivedPalletCount)) { validationError.value = '累计实收托数须为正整数,且不能少于已实收托数'; return null @@ -70,7 +80,9 @@ function buildOperation(): TruckOperation | null { async function submit() { if (saving.value || !active.value || !commands.value.includes(active.value)) return validationError.value = ''; error.value = null - const operation = buildOperation() + // Keep an operation immutable after an uncertain response so a retry cannot + // accidentally use a different version, idempotency key, or edited payload. + const operation = pending.value || buildOperation() if (!operation) return pending.value = operation; saving.value = true const customerId = props.detail.customerId, orderNo = props.detail.orderNo @@ -82,7 +94,7 @@ async function submit() { emit('updated', updated) } catch (cause) { if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录') - } finally { pending.value = null; saving.value = false } + } finally { saving.value = false } } @@ -98,6 +110,7 @@ async function submit() {

可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。

通过拒绝 +