From 6dbcb2128cf97eb0216d4bdcbedb4a87d51e1cd8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 20:20:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FinanceFeeSourceEventService.java | 2 +- .../FinanceFeeSourceEventServiceTest.java | 15 ++-- .../dropship-pallet-matrix-contract.test.mjs | 9 +++ .../dropship-pallet-matrix.mjs | 70 ++++++++++++++++++- .../phase3-outbound-dispatch.mjs | 13 ++++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index a49def176..b36277561 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -92,7 +92,7 @@ public class FinanceFeeSourceEventService { snapshot.sourceType(), snapshot.feeId()); jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='CONFIRMED'", snapshot.sourceType(), snapshot.feeId()); - jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')", + jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'APPROVED','AVAILABLE')", snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(), snapshot.inboundCode() == null || snapshot.inboundCode().isBlank() ? snapshot.containerNo() : snapshot.inboundCode(), snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 8bceb7e2f..5cee9c88a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -50,7 +50,8 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(firstleg).inbound("test-token", 21, 31, 1); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class)); } @Test @@ -63,7 +64,8 @@ class FinanceFeeSourceEventServiceTest { verify(firstleg).container("test-token", 41, 31, 1); verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L)); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); assertThat(values.getValue()[8]).as("fin_fee_source.container_id keeps loadingPlanId") .isEqualTo(41L); assertThat(values.getValue()[9]).as("fin_fee_source.container_booking_id stores physical container") @@ -107,7 +109,8 @@ class FinanceFeeSourceEventServiceTest { verify(supplierValidator).requireActive(71L, "TRUCKING"); verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); assertThat(values.getValue()[20].toString()) .contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\""); } @@ -145,7 +148,8 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); assertThat(values.getValue()[14]).isEqualTo("DIRECT_PAID"); assertThat(values.getValue()[16]).isEqualTo("BANK_TRANSFER"); assertThat(values.getValue()[18]).isEqualTo("[901]"); @@ -171,7 +175,8 @@ class FinanceFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(101L); verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L)); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class)); } @Test diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index fff00b32a..c98d7e34b 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -5,6 +5,15 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') +test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => { + assert.match(source, /await outboundOverseas\(group, picker.token\)/) + assert.match(source, /one-click-dropship\?customerId=/) + assert.match(source, /position.inboundCode === candidate.inboundCode/) + assert.match(source, /cartonMarks: inputs\[index\].cartonMarks/) + assert.match(source, /outbound.outbound\?\.status === 'OUTBOUNDED'/) + assert.match(source, /!remaining.some/) +}) + test('dropship matrix has a real customer prerequisite without generic dispatch fixtures', () => { const modules = normalizeModuleOrder(['dropship-pallet-matrix']) assert.ok(modules.includes('merchant-qualification-fixture')) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index ec3b71b67..f07bc043f 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -321,7 +321,8 @@ export async function run(context) { async function receiveOverseas(group) { const record = group[0] const label = record.label - context.marker = `${marker}-${label}-RECEIVE` + // The PDA account helper keeps the marker tail; retain the run identity there. + context.marker = `${label}-RECEIVE-${marker}` const base = '/api/pda/v1/last-leg/receiving-containers' const access = await resolvePickerAccess(context) check(`${label}海外PDA角色可用`, null, Boolean(access)) @@ -376,10 +377,75 @@ export async function run(context) { check(`${label}海外入库完成且重放稳定`, null, completed.status === 'COMPLETED' && JSON.stringify(replay) === JSON.stringify(completed)) for (const item of group) item.overseas.receiving = { completed, current, receivingVerified: true } + await outboundOverseas(group, picker.token) } finally { await disablePicker(context, picker, records.indexOf(record) + 1) } } + async function outboundOverseas(group, token) { + const label = group[0].label + const base = '/api/platform/v1/last-leg/outbound-orders' + const customerId = String(group[0].task.tenantId) + const warehouseId = group[0].overseas.warehouseId + async function call(name, method, path, body, pda = false) { + const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken) + const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) + check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null) + return value + } + const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}` + const all = await call('读取专用出库可用库存', 'GET', candidatePath) + const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode)) + const inputs = selected.map((candidate) => { + const record = group.find((item) => item.source.inboundCode === candidate.inboundCode) + const pallet = record.task.pallets.find((item) => item.palletNo === candidate.palletNo) + const cartonMarks = (pallet?.cartons || []).filter((carton) => + String(carton.inboundId) === String(record.source.inboundId)).map((carton) => carton.boxCode) + check(`${label}专用出库候选逐票逐托箱数准确`, null, + cartonMarks.length > 0 && Number(candidate.availablePackages) === cartonMarks.length + && String(candidate.customerId) === customerId && String(candidate.overseasWarehouseId) === warehouseId) + return { inventoryPositionId: candidate.inventoryPositionId, packages: cartonMarks.length, + sku: candidate.skuCode, cartonMarks } + }) + check(`${label}专用出库候选覆盖全部票和箱`, null, group.every((record) => + selected.filter((item) => item.inboundCode === record.source.inboundCode) + .reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length)) + const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs, + remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` } + const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) + for (const record of group) record.outbound = { task, selected, completed: false } + const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) + check(`${label}专用出库重放不重复建单`, null, JSON.stringify(replay) === JSON.stringify(task)) + const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` + const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) + check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) + for (const [index, candidate] of selected.entries()) { + const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode) + check(`${label}PDA拣货来源仓位和入仓码一致`, null, Boolean(item) + && item.positions?.some((position) => String(position.inventoryPositionId) === String(candidate.inventoryPositionId) + && position.inboundCode === candidate.inboundCode)) + const pick = { outboundPalletItemId: item.outboundPalletItemId, + inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages, + cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } + const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true) + const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true) + check(`${label}PDA出库拣货重放不重复扣减`, null, JSON.stringify(repeated) === JSON.stringify(picked)) + } + const completion = { idempotencyKey: `${context.marker}-outbound-complete` } + const completed = await call('PDA完成专用出库', 'POST', `${pdaPath}/complete`, completion, true) + const repeated = await call('PDA专用出库完成重放', 'POST', `${pdaPath}/complete`, completion, true) + check(`${label}专用出库任务完成且重放稳定`, null, + completed.status === 'COMPLETED' && JSON.stringify(repeated) === JSON.stringify(completed)) + const outbound = await call('回查专用出库单终态', 'GET', `${base}/${task.outboundNo}`) + check(`${label}专用出库单真实完成且箱数核平`, null, + outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundType === 'ONE_CLICK_DROPSHIP' + && Number(outbound.outbound.packageCount) === inputs.reduce((sum, item) => sum + item.packages, 0) + && outbound.pallets.every((item) => Number(item.packageCount) === Number(item.pickedPackageCount))) + const remaining = await call('回查专用出库可用库存扣减', 'GET', candidatePath) + check(`${label}本轮每票库存全部扣减`, null, + !remaining.some((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))) + for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } + } try { await verify(await prepare('SINGLE-MULTI', 2), 2) await verify(await prepare('SINGLE-SINGLE', 1), 1) @@ -402,7 +468,7 @@ export async function run(context) { await convertOverseas(group) await receiveOverseas(group) } - context.createdResources.dropshipPalletMatrix.stage = 'OVERSEAS_RECEIVING_COMPLETED' + context.createdResources.dropshipPalletMatrix.stage = 'DEDICATED_OUTBOUND_COMPLETED' } finally { context.marker = marker context.options.prepareDropshipManualCount = savedOptions diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index f264f98c8..bd4d7a7ca 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -91,6 +91,19 @@ async function verifyTruckDeparturePayables(context, aggregate) { && row.direction === 'PAYABLE' && row.reviewStatus === 'APPROVED' && Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceFeeId)).amount) * 100)), `expected=${fees.size}, imported=${imported.size}`, OPTIONS) + const candidates = new Map() + for (const customer of new Set([...fees.values()].map(fee => id(fee.tenantId)))) { + const response = await request(context, 'GET', `/api/platform/finance/v1/fee-pool?customerId=${customer}&direction=PAYABLE&supplierId=${id(truck.carrierId)}`) + const rows = context.apiSuccess(MODULE, '回查拖车费可用于应付结算', response, '', OPTIONS) + for (const row of Array.isArray(rows) ? rows : []) { + if (row.sourceType === 'CONTAINER_FEE' && fees.has(id(row.sourceId))) candidates.set(id(row.sourceId), row) + } + } + context.assert(MODULE, '拖车费已进入应付结算候选且未付款', null, + candidates.size === fees.size && [...candidates.values()].every(row => row.direction === 'PAYABLE' + && row.settlementMode === 'ON_ACCOUNT' && row.status === 'AVAILABLE' && !row.paidAt + && Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceId)).amount) * 100)), + `expected=${fees.size}, candidates=${candidates.size}`, OPTIONS) } export async function run(context) {