From 9ea4ccb9b125e88dd346ce39cafc1d227d3ff3c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 14:39:28 +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 --- lastleg-service/AGENTS.md | 2 +- .../lastleg/mapper/LastlegOrderMapper.java | 8 +++++++- .../resources/openapi/overseas-business-v1.yaml | 2 +- .../ContainerPlanProgressContractTest.java | 4 ++++ .../manual-state-ocean-trucking.mjs | 6 +++--- .../phase3-container-customs-fixture.mjs | 17 +++++++++++++++-- .../phase4-carrier-nodes.mjs | 3 ++- .../phase4-overseas-pda.mjs | 2 +- 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index fd9e111ed..8ff6423e1 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -12,7 +12,7 @@ | 功能 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | -| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、计划数、未操作数和 `allPlansReady`,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、计划数、未操作数和 `allPlansReady`,分页前排除 `allPlansReady=true`,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 28f6e1884..86e54d362 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -20,7 +20,13 @@ public interface LastlegOrderMapper extends BaseMapper { "AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') " + "AND o.created_at >=#{q.createdFrom} " + "AND o.created_at <=#{q.createdTo} " + - "GROUP BY o.source_container_booking_id "; + "GROUP BY o.source_container_booking_id " + + "HAVING SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)>0 " + + "OR SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL " + + "OR NULLIF(TRIM(p.status),'') IS NULL THEN 1 ELSE 0 END)>0 " + + "OR COUNT(DISTINCT p.id)<>COUNT(*) " + + "OR COUNT(NULLIF(o.container_no,''))<>COUNT(*) " + + "OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 "; String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," + "CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " + diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 4749a8111..be0865da4 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -170,7 +170,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries: get: summary: 分页查询柜级入库计划进度 - description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 + description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回 allPlansReady=false 的待处理柜,已全部生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java index 760806c8e..6d19343c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java @@ -38,6 +38,8 @@ class ContainerPlanProgressContractTest { String sql = String.join("", rows.value()).toLowerCase(); assertTrue(sql.contains("left join ll_inbound_plan")); assertTrue(sql.contains("o.estimated_warehouse_at")); + assertTrue(sql.contains("having sum(case when nullif(trim(o.inbound_code),'') is null then 1 else 0 end)>0")); + assertTrue(sql.contains("or count(distinct p.id)<>count(*)")); assertTrue(sql.contains("progress.plannedarrivalat asc")); assertTrue(sql.contains("progress.sourcecontainerbookingid asc")); assertTrue(sql.contains("limit #{limit} offset #{offset}")); @@ -54,6 +56,7 @@ class ContainerPlanProgressContractTest { assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:")); assertTrue(openApi.contains("ContainerPlanProgressSummary:")); assertTrue(openApi.contains("allPlansReady:")); + assertTrue(openApi.contains("仅返回 allPlansReady=false 的待处理柜")); assertTrue(openApi.contains("ll_order.estimated_warehouse_at")); assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); @@ -72,6 +75,7 @@ class ContainerPlanProgressContractTest { assertTrue(sql.contains("source_container_booking_id<>''")); assertTrue(sql.contains("LEFT JOIN ll_inbound_plan")); + assertTrue(sql.contains("HAVING SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)>0")); assertTrue(!sql.contains("<")); } } diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index bb4c6f06e..1a489baff 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -17,8 +17,8 @@ async function post(context, path, body, operation) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': key(context, operation) }) } -async function uploadBillOfLading(context, appointmentId, index) { - const path = `${TRUCK_BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` +async function uploadDispatchOrder(context, appointmentId, index) { + const path = `${TRUCK_BASE}/${appointmentId}/attachments/upload?attachmentType=DISPATCH_ORDER` const form = new FormData() const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck-${index}.png`) @@ -127,7 +127,7 @@ async function createTruck(context, oceanBooking, index) { const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) - const billUpload = await uploadBillOfLading(context, id(value.id), index) + const billUpload = await uploadDispatchOrder(context, id(value.id), index) const billAttachment = context.apiSuccess(MODULE, `拖车状态样本 ${index} 上传提单附件`, billUpload, '', OPTIONS) if (!billAttachment?.id) throw new Error(`拖车样本 ${index} 未上传有效提单附件`) return value diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 8ba9e6af8..426576fc7 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -133,9 +133,18 @@ function routes(context, merchantToken) { } export async function prepare(context) { + const outerMarker = context.marker const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, { primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND', }) + const executionMarker = `${outerMarker}-CUSTOMS-MIXED` + context.marker = executionMarker + if (context.createdResources?.orchestrationFixtures?.phase3Loading) { + context.createdResources.orchestrationFixtures.phase3Loading.marker = executionMarker + } + if (context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental) { + context.createdResources.orchestrationFixtures.phase3LoadingIncremental.marker = executionMarker + } const loading = await import('./phase3-loading.mjs') await context.executeModule(`${MODULE}-loading`, () => loading.run(context)) const finalize = await import('./phase3-loading-finalize.mjs') @@ -143,6 +152,7 @@ export async function prepare(context) { const source = context.createdResources?.phase3Loading if (!source?.loadingPlanId || !source?.containerBookingId || source?.finalStatus !== 'LOADED') { + context.marker = outerMarker restore() throw new Error(`混合柜未进入LOADED: planId=${id(source?.loadingPlanId) || 'missing'}, status=${source?.finalStatus || 'missing'}`) } @@ -157,6 +167,7 @@ export async function prepare(context) { buy.length > 0 && refund.length > 0 && inbounds.every((item) => /^\d{16,}$/.test(id(item.inboundId))), `buy=${buy.length}, tax=${refund.length}, inbounds=${inbounds.length}`, OPTIONS) if (!buy.length || !refund.length) { + context.marker = outerMarker restore() throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`) } @@ -178,10 +189,11 @@ export async function prepare(context) { const buyInboundIds = new Set(buy.map((item) => id(item.inboundId))) const planBuyRows = planItems.filter((item) => buyInboundIds.has(id(item.inboundId))).length const expectedBuyRows = planBuyRows > 0 ? planBuyRows : buy.length + context.marker = outerMarker context.createdResources.phase3ContainerCustomsFixtures = { - marker: context.marker, requestedKinds: ['MIXED'], + marker: outerMarker, requestedKinds: ['MIXED'], containers: [{ - kind: 'MIXED', marker: context.marker, status: 'LOADED', autoDispatchMode: 'IMMEDIATE', + kind: 'MIXED', marker: outerMarker, status: 'LOADED', autoDispatchMode: 'IMMEDIATE', loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), oceanBookingId: id(source.oceanBookingId), containerNo: source.containerNo, inbounds: inbounds.map((item) => ({ @@ -194,6 +206,7 @@ export async function prepare(context) { context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken) return () => { delete context.context.phase3ContainerCustomsRoutes + context.marker = outerMarker restore() } } diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index f89fc08e3..6ea5d61e8 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -18,6 +18,7 @@ function nullableText(value) { return value == null || typeof value === 'string' function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } +function validOperatorId(value) { return typeof value === 'string' && /^\d+$/.test(value) } function validContainerNo(value) { if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, @@ -88,7 +89,7 @@ function validHistory(item) { return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) - && nullableText(item?.reason) && (item?.operatorId == null || validNumericId(item.operatorId)) + && nullableText(item?.reason) && (item?.operatorId == null || validOperatorId(item.operatorId)) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index eb54b6b40..7ce5fb9f3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -244,7 +244,7 @@ async function awaitProducerConsumption(context, producers) { if (!expected.length) return true const consumed = new Set() const lastResponses = new Map() - for (let attempt = 1; attempt <= 30; attempt += 1) { + for (let attempt = 1; attempt <= 90; attempt += 1) { const missing = expected.filter((containerNo) => !consumed.has(containerNo)) const results = await Promise.all(missing.map(async (containerNo) => { const response = await context.requestOverseas('GET',