diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 2689a8f9e..633c88542 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -613,12 +613,13 @@ public class FbaPricingService { } private static boolean preserveProcessStatusDuringPricing(String processStatus) { - return List.of("WAIT_PALLET", "WAIT_SPLIT", "LOADING").contains(processStatus); + return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus); } private static boolean feeEditableBeforeLoading(String processStatus) { return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", - "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING").contains(processStatus); + "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING") + .contains(processStatus); } private static PricingDestinationType destinationType(InboundPricingEntity inbound) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index ce25a475a..9bede13f7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -316,6 +316,24 @@ class FbaPricingServiceTest { verify(priceBookEstimator).estimate(eq(TENANT), any()); } + @Test + @DisplayName("一件代发待合托状态允许初算且不改变作业状态") + void calculatesDropshipFeeWhileWaitingForPalletization() { + readyRepository(); + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_PALLETIZE"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("CBM", "1.0000", "10", "10", "0")); + + FeeSnapshotResponse response = service.calculate( + TENANT, OPERATOR, INBOUND, NOW, "dropship-initial-pricing"); + + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLETIZE"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLETIZE", "CBM", + new BigDecimal("1.0000")); + } + @Test @DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用") void recalculatesSentFeeBeforeCustomerConfirmation() { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 00f84f65b..737cba406 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -89,7 +89,7 @@ export async function run(context) { | 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | -| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO、PU)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | +| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | | 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 | | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | @@ -102,7 +102,7 @@ export async function run(context) { 海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 -节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER`、`PICKUP_ORDER` 三类目的港资料,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 +节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 440187168..3ec849814 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -8,15 +8,16 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') -test('three generic container documents are frozen and customs stays on customs-v1', () => { +test('destination clearance and delivery order documents are frozen while pickup uses the cabinet code', () => { assert.deepEqual(DOCUMENT_TYPES, [ - 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) for (const token of [ '/clearance/document/upload', '/clearance/complete', '/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key', ]) assert.ok(source.includes(token), token) assert.doesNotMatch(source, /customs\/document|CUSTOMS_DECLARATION/) + assert.doesNotMatch(source, /PICKUP_ORDER/) assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index e6800753f..c76a65d4e 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -3,7 +3,7 @@ const OPTIONS = { scenario: 'phase3-container-operational-documents', deployment const CONTAINERS = '/api/platform/v1/containers' export const DOCUMENT_TYPES = Object.freeze([ - 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) function id(value) { return value == null ? '' : String(value) } @@ -122,7 +122,7 @@ export async function run(context) { readPath: `${CONTAINERS}/${containerId}/clearance`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`, }, - ...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({ + ...['DELIVERY_ORDER'].map((type) => ({ type, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, readPath: `${CONTAINERS}/${containerId}/documents/${type}`, diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 43f9bca8e..3baabdd5d 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -92,7 +92,7 @@ export async function run(context) { } } - for (const documentType of ['DELIVERY_ORDER', 'PICKUP_ORDER']) { + for (const documentType of ['DELIVERY_ORDER']) { const response = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/documents/${documentType}`) const data = context.apiSuccess(MODULE, `查询 ${documentType} 单证`, response, `documentType=${documentType}`, OPTIONS) context.assert(MODULE, `${documentType} 单证归属货柜`, response, diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs index 68cdb1706..e3ea4c483 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs @@ -14,7 +14,8 @@ test('current-marker departure batch separates domestic customs evidence from po const documents = read('phase3-container-documents.mjs') const outbound = read('phase3-outbound-dispatch.mjs') assert.doesNotMatch(customs, /DESTINATION_CLEARANCE|DELIVERY_ORDER|PICKUP_ORDER/) - for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) assert.match(documents, new RegExp(type)) + for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER']) assert.match(documents, new RegExp(type)) + assert.doesNotMatch(documents, /PICKUP_ORDER/) for (const evidence of ['上传', '回读', '完成']) assert.match(documents, new RegExp(evidence)) assert.match(customs, /整柜聚合回显国内报关资料与真实PDA照片证据/) assert.match(customs, /photos\.length >= 5/)