diff --git a/AGENTS.md b/AGENTS.md index fe2d5c9d4..2b22b6897 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,11 @@ | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | -| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | +| 订舱资料/打印、船期、订舱拥有的拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车不带客户归属,必须绑定 `RELEASED` 订舱及其真实柜,订舱文件联查 `file/AGENTS.md` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | @@ -97,12 +98,15 @@ ## 全量 HTTP 验收脚本路由 +- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。 - 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 +- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 +- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。 ## 阶段文档入口 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 34ae1509a..a1936915e 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -19,6 +19,11 @@ public record FirstLegLoadingPlanCompletedV2( containerNo = ContainerNumbers.requireCanonical(containerNo); orders = orders == null ? List.of() : List.copyOf(orders); if (orders.isEmpty()) throw new IllegalArgumentException("orders are required"); + Order owner = orders.get(0); + if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId()) + || !owner.customerName().equals(order.customerName()))) { + throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot"); + } } public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, @@ -47,6 +52,11 @@ public record FirstLegLoadingPlanCompletedV2( if (customerName == null || customerName.isBlank()) { throw new IllegalArgumentException("customerName is required"); } + customerId = customerId.trim(); + customerName = customerName.trim(); + if (customerId.length() > 128 || customerName.length() > 128) { + throw new IllegalArgumentException("customerId/customerName snapshot is too long"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 3864bd3d1..7b6f937f5 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -118,6 +118,22 @@ class FirstLegLoadingPlanCompletedV2Test { .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName"); } + @Test + void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() { + var first = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of()); + assertThat(first.customerId()).isEqualTo("11"); + assertThat(first.customerName()).isEqualTo("客户甲"); + + var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order( + "3", "3", "4", "ORD-2", "4", "RK-2", null, null, null, + 1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of()); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName"); + } + @Test void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception { String yaml; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index aa410bda8..e44663631 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9458,12 +9458,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照', store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', - actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', + actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', @@ -13973,7 +13974,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话'); @@ -13989,7 +13990,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_cou CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消'); diff --git a/file/AGENTS.md b/file/AGENTS.md index 56be4d5de..032d85031 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -18,6 +18,7 @@ | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | +| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 49895f9d6..ce9af6f84 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -59,14 +59,15 @@ public class FinanceExportProviderService { RECEIVABLE_STATISTICS); private static final Set SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); private static final Set LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", - "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID"); + "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", + "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID"); private static final List ACCOUNT_COLUMNS = List.of( column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), - column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), + column("outstandingReceivable", "待收客户款(CNY)", ExportColumnType.DECIMAL, 18), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16), - column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), + column("paidCost", "已线下支付供应商款(CNY)", ExportColumnType.DECIMAL, 24), column("outstandingPayable", "待付供应商款(CNY)", ExportColumnType.DECIMAL, 18), column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); private static final List LEDGER_COLUMNS = List.of( column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 6ead12ece..12976c3d2 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -112,6 +112,24 @@ class FinanceExportProviderServiceTest { verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null); } + @Test + void accountExportUsesOperatingFundNamesAndAcceptsSupplierPaymentLedgerFilters() { + when(accountService.list(1, 100, null, null, 10001L)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + var account = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("customerId", "10001"), null, null)); + assertThat(account.columns()).extracting(column -> column.title()).contains( + "待收客户款(CNY)", "待付供应商款(CNY)", "已线下支付供应商款(CNY)"); + + for (String businessType : List.of("SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT")) { + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, + request(Map.of("customerId", "10001", "businessType", businessType), null, null)); + verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null); + } + } + @Test void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() { when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of( diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 5aaa6edd5..78a806cc4 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -36,11 +36,11 @@ | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | -| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | -| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` | +| 订舱拥有的拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | @@ -63,9 +63,10 @@ - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱,不复制到拖车附件。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 -- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 432654b08..43e6ae875 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index 10cf72b9a..3d8b72ee1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -45,7 +45,6 @@ public class TruckAppointmentEntity { private LocalDateTime actualDepartedAt; private Integer transitMinutes; private BigDecimal payableAmount; - private BigDecimal receivableAmount; private String currency; private TruckAppointmentStatus status; private Integer revision; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 0f842494b..b2139d8f5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,11 +1,12 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ + @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List activeForUpdate(@Param("containerBookingId") long containerBookingId); @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index 232ce6740..4bbcce2df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} - public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} + public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index b5a77648f..d833afbbc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -123,11 +123,13 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") + .contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果"); + } InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId()); requireVersion(request.version(), measurement.getVersion()); - boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") - .contains(inbound.getProcessStatus()); - if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) + if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) .contains(measurement.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核"); } @@ -135,19 +137,15 @@ public class InboundMeasurementService { List weights = repository.listWeights(tenantId, inboundId); ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); - if (!alreadyAdvanced) { - requireConfirmedPackageTotal(inbound, items); - measurement.setStatus(STATUS_SUBMITTED); - if (!repository.updateMeasurement(measurement)) { - throw concurrentModification(); - } + requireConfirmedPackageTotal(inbound, items); + measurement.setStatus(STATUS_SUBMITTED); + if (!repository.updateMeasurement(measurement)) { + throw concurrentModification(); } - if (!alreadyAdvanced) { - inbound.setProcessStatus("MEASURE_PENDING"); - inbound.setMeasureReviewStatus("PENDING"); - inbound.setMeasureCompletedAt(LocalDateTime.now()); - } + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + inbound.setMeasureCompletedAt(LocalDateTime.now()); if (measurement.getTotalPackageCount() != null) { inbound.setTotalPackageCount(measurement.getTotalPackageCount()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 663cc8c0e..eb203f7dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -54,6 +54,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.slf4j.Logger; @@ -86,6 +87,8 @@ public class LoadingPickService { @Resource private LoadingCompletionCommandRepository completionCommands; @Resource + private TransactionTemplate completionTransaction; + @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private OceanBookingRepository oceanBookingRepository; @@ -565,6 +568,14 @@ public class LoadingPickService { "INBOUND_COMPLETED"); } } + completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName()); + } + + /** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */ + private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker, + LoadingPickTaskEntity task, List taskGroups) { + long taskId = task.getId(); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数")); } @@ -594,7 +605,6 @@ public class LoadingPickService { if (!completionCommands.complete(command.getId(), worker)) { throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化")); } - completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName()); } private void requireColoadConservation(LoadingPickTaskEntity task, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 6700292b4..6ec6882ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -72,8 +72,14 @@ public class MeasurementReviewService { } if (approved) { var items = repository.listMeasurementItems(tenantId, measurement.getId()); + if (items.isEmpty() || items.stream().anyMatch(item -> item.getPackageCount() == null + || item.getPackageCount() <= 0 || item.getTotalVolumeCbm() == null + || item.getTotalVolumeCbm().signum() <= 0 || item.getVolumeWeightKg() == null + || item.getVolumeWeightKg().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "测量明细箱数、体积和体积重必须完整且大于零,才能审核通过"); + } long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount) - .filter(java.util.Objects::nonNull) .mapToLong(Integer::longValue) .sum(); if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) { @@ -97,13 +103,23 @@ public class MeasurementReviewService { .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm) - .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal volumeWeight = items.stream() + .map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getVolumeWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null || order.getActualGrossWeightKg().compareTo(grossWeight) != 0 || order.getActualNetWeightKg().compareTo(netWeight) != 0 - || volume.signum() <= 0) { + || order.getActualVolumeCbm() == null || order.getActualVolumeCbm().compareTo(volume) != 0 + || order.getVolumeWeightKg() == null || order.getVolumeWeightKg().compareTo(volumeWeight) != 0 + || measurement.getTotalPackageCount() == null + || measurement.getTotalPackageCount() != confirmed + || measurement.getTotalVolumeCbm() == null + || measurement.getTotalVolumeCbm().compareTo(volume) != 0 + || measurement.getVolumeWeightKg() == null + || measurement.getVolumeWeightKg().compareTo(volumeWeight) != 0) { throw new BizException(ResultCode.VALIDATION_FAILED, - "审核通过前称重毛重、货物净重汇总和体积必须完整且一致"); + "审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 480b7b35b..bd2fe0d6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile; @Service public class TruckAppointmentService { private static final Set ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER"); + private static final Set CURRENCIES = Set.of("RMB", "USD"); @Resource TruckAppointmentRepository repo; @Resource OceanBookingRepository oceanBookings; @Resource OceanBookingDocumentService oceanBookingDocuments; @@ -51,7 +52,7 @@ public class TruckAppointmentService { @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} - @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);AssociationContext links=validateAssociations(r);if(!repo.activeForUpdate(r.containerBookingId()).isEmpty())throw conflict("当前真实柜已有有效拖车预约");var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw conflict("当前真实柜已有有效拖车预约");throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @@ -79,7 +80,7 @@ public class TruckAppointmentService { private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} @@ -102,6 +103,8 @@ public class TruckAppointmentService { } private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} + private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;} + private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");} private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;} private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index f42423188..d271e784e 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', - receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', @@ -44,7 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4b8b6c640..ffef274fd 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1328,10 +1328,10 @@ components: type: array maxItems: 100 items: {$ref: '#/components/schemas/WeightPalletSnapshot'} - page: {type: integer, format: int64, minimum: 1} - size: {type: integer, format: int64, minimum: 1, maximum: 100} - total: {type: integer, format: int64, minimum: 0} - pages: {type: integer, format: int64, minimum: 0} + page: {oneOf: [{type: integer, format: int64, minimum: 1}, {type: string, pattern: '^[1-9][0-9]*$'}]} + size: {oneOf: [{type: integer, format: int64, minimum: 1, maximum: 100}, {type: string, pattern: '^(?:[1-9]|[1-9][0-9]|100)$'}]} + total: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]} + pages: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]} CreateWeightResultRequest: type: object required: [fileId, palletId] diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 7833a4d09..57c9df103 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -21,6 +21,7 @@ paths: records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} post: summary: Create a truck appointment for a released booking and its allocated real container + description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates. requestBody: required: true content: @@ -28,6 +29,7 @@ paths: schema: {$ref: '#/components/schemas/SaveRequest'} responses: '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: @@ -187,6 +189,8 @@ components: carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.} plannedLoadingAt: {type: string, format: date-time} + payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.} + currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.} expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} DetailStableReferences: type: object @@ -209,4 +213,6 @@ components: carrierId: {type: integer, format: int64} carrierName: {type: string} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.} + payableAmount: {type: number, format: double, minimum: 0, nullable: true} + currency: {type: string, enum: [RMB, USD]} attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 350256a7b..4ffcde1c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} - @Test void schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");} + @Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");} @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); @@ -30,8 +30,10 @@ class TruckAppointmentControllerContractTest { } } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} + @Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");} @Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} + @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 33976049a..6f51a5a7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest; import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; import com.baiyue.firstleg.customs.client.CustomsFileClient; @@ -108,6 +109,35 @@ class ContainerCustomsServiceTest { verify(mapper,never()).freezeScopes(anyLong()); } + @Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){ + var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var declaring=container(ContainerCustomsService.DECLARING,3); + var source=document(1L); + var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1}); + when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); + when(mapper.container(9L)).thenReturn(declaring); + when(mapper.document(1L)).thenReturn(source); + when(mapper.lockContainerById(2L)).thenReturn(declaring); + + assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1))) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + + verifyNoInteractions(files); + verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong()); + verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong()); + verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt()); + verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong()); + verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong()); + assertThat(source.version()).isEqualTo(1); + assertThat(source.status()).isEqualTo("ACTIVE"); + } + @Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception { var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); var ready=container(ContainerCustomsService.READY,2); @@ -271,6 +301,27 @@ class ContainerCustomsServiceTest { verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); } + @Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){ + var declaring=container(ContainerCustomsService.DECLARING,3); + var completed=container(ContainerCustomsService.COMPLETED,4); + var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION"); + when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + when(mapper.planInbounds(9L)).thenReturn(List.of(buy)); + when(mapper.complete(2L,3,5L,"admin")).thenReturn(1); + when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1); + when(mapper.container(9L)).thenReturn(completed); + when(mapper.documents(2L,null)).thenReturn(List.of()); + + var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成")); + + assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED); + verify(mapper,times(1)).complete(2L,3,5L,"admin"); + verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"), + eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin")); + verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L)); + } + private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 113d1256e..ff9e7681c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringJUnitConfig(LoadingDeliveryService.class) class LoadingDeliveryServiceTest { @@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest { verify(cabinetEvidence).requireComplete(99L); } + + @Test + void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception { + Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8); + when(repository.completionTarget(10L, 20L)).thenReturn(null); + when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-full"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(refreshed); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1); + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result"); + + service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done"); + + verify(repository).completeLoading(10L, 20L, 9L, 8); + } + + @Test + void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception { + var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget( + 20L, 21L, 22L, "LOADING", 6, "LOADING", 9); + Snapshot source = new Snapshot(20L, 10L, "LOADING", 6); + Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9); + when(repository.completionTarget(10L, 20L)).thenReturn(target); + when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-partial"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(source); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1); + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result"); + + service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done"); + + verify(repository).findInboundForUpdate(10L, 21L); + verify(repository).completeLoadedChild(10L, 20L, 9L, 9); + } + + @Test + void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() { + Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8); + when(repository.completionTarget(10L, 20L)).thenReturn(null); + when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed); + when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"), + eq("pda-conflict"), anyString())).thenReturn(true); + when(repository.findInbound(10L, 20L)).thenReturn(refreshed); + when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress()); + when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L); + when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0); + try { + when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request"); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + throw new AssertionError(exception); + } + + assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", + "pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + + verify(repository).completeLoading(10L, 20L, 9L, 8); + verify(histories, never()).insert(org.mockito.ArgumentMatchers + .any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class)); + } + + private static InboundProgress resolvedProgress() { + InboundProgress progress = new InboundProgress(); + progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1); + progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0); + return progress; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java index e64e74a02..aa534a998 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java @@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest { verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class)); } + @Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() { + MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf", + "application/pdf", "confirmation".getBytes()); + MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf", + "application/pdf", "bill".getBytes()); + when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) + .thenReturn(true); + when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1)); + when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation)) + .thenReturn(ApiResponse.success(metadata(91L))); + when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill)) + .thenReturn(ApiResponse.success(metadata(92L))); + when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1); + when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) + .thenReturn(1); + + var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key", + confirmation, 7L, "admin"); + var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin"); + + assertThat(first.bookingVersion()).isEqualTo(1); + assertThat(second.bookingVersion()).isEqualTo(2); + ArgumentCaptor documents = + ArgumentCaptor.forClass(OceanBookingDocumentEntity.class); + verify(repository, times(2)).insert(documents.capture()); + assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType) + .containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING"); + assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId) + .containsExactly(91L, 92L); + } + @Test void attachRejectsCrossScopeFileBeforePersisting() { when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findForUpdate(10L)).thenReturn(draft(1)); @@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest { verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); } + @Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() { + OceanBookingEntity booking = draft(3); + OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION"); + OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING"); + when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill)); + + assertThatThrownBy(() -> service.requireActiveValidDocument(booking)) + .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); + + verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L); + verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString()); + } + + private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) { + OceanBookingDocumentEntity document = new OceanBookingDocumentEntity(); + document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId); + document.setDocumentType(type); document.setValid(true); + return document; + } + private static OceanBookingEntity draft(int version) { OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 9b9ea8c0f..97b020c93 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 6cbe4853a..d373d1acb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest { String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0]; assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE") .contains("'COMPLETED','CANCELLED','EXCEPTION'"); + String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0]; + assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE") + .contains("'COMPLETED','CANCELLED','EXCEPTION'"); String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); @@ -34,5 +37,6 @@ class TruckAppointmentMapperContractTest { "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName"); + assertThat(edit).doesNotContain("receivable_amount", "receivableAmount"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 22c81459b..0d43b500d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -234,6 +234,38 @@ class InboundMeasurementServiceTest { assertThat(result.confidence()).isEqualByComparingTo("0.50972"); } + @Test + void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + InboundMeasurementResponses.WeightResult created = service.createWeightResult( + 1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null)); + + assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL"); + ArgumentCaptor saved = + ArgumentCaptor.forClass(InboundWeightResultEntity.class); + verify(repository).insertWeight(saved.capture()); + InboundWeightResultEntity weight = saved.getValue(); + weight.setId(20L); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认")); + + assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500"); + verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class)); + } + @Test void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -254,6 +286,30 @@ class InboundMeasurementServiceTest { verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"); } + @Test + void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5")) + .thenThrow(mock(feign.FeignException.class)); + + assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5")) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库服务暂不可用"); + } + + @Test + void weightPalletOptionsFailsClosedOnWarehouseNetworkError() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null)) + .thenThrow(mock(feign.RetryableException.class)); + + assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库服务暂不可用"); + } + @Test void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -775,9 +831,13 @@ class InboundMeasurementServiceTest { } @Test - void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() { + void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() { InboundOrderEntity inbound = inbound("WAIT_SPLIT"); inbound.setMeasureReviewStatus("APPROVED"); + inbound.setActualGrossWeightKg(new BigDecimal("40.000")); + inbound.setActualNetWeightKg(new BigDecimal("30.000")); + inbound.setActualVolumeCbm(new BigDecimal("2.0000")); + inbound.setVolumeWeightKg(new BigDecimal("334.000")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(30L); measurement.setStatus("APPROVED"); @@ -791,14 +851,17 @@ class InboundMeasurementServiceTest { when(repository.listWeights(1L, 10L)).thenReturn(List.of()); when(repository.updateInbound(inbound)).thenReturn(true); - InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, - new InboundMeasurementRequests.SubmitRequest(30L, 2)); + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("审核完成"); - assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); - assertThat(result.measureReviewStatus()).isEqualTo("APPROVED"); - assertThat(measurement.getStatus()).isEqualTo("APPROVED"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000"); verify(repository, never()).updateMeasurement(any()); - verify(repository).updateInbound(inbound); + verify(repository, never()).updateInbound(any()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 196529b95..86557fd2e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -44,10 +44,13 @@ import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.time.LocalDateTime; +import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -74,6 +77,8 @@ class LoadingPickServiceTest { @MockBean private LoadingCompletionCommandRepository completionCommands; @MockBean + private TransactionTemplate completionTransaction; + @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private OceanBookingRepository oceanBookingRepository; @@ -100,6 +105,12 @@ class LoadingPickServiceTest { @BeforeEach void activeOperationalContainer() { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer action = invocation.getArgument(0); + action.accept(mock(TransactionStatus.class)); + return null; + }).when(completionTransaction).executeWithoutResult(any()); ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); booking.setContainerNo("CSLU1234567"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 54792c723..78aa5cca2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -91,6 +91,7 @@ class MeasurementReviewServiceTest { InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); item.setPackageCount(5); item.setTotalVolumeCbm(new BigDecimal("0.7480")); + item.setVolumeWeightKg(new BigDecimal("124.916")); when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); @@ -118,6 +119,40 @@ class MeasurementReviewServiceTest { verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); } + @Test + @DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT") + void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100"); + InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816"); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second)); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku", + new MeasurementReviewRequest(20L, 3, null)); + + ArgumentCaptor history = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(fixture.repository).insertHistory(history.capture()); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + @DisplayName("体积或体积重任一层汇总不一致时审核零写入") + void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() { + Fixture fixture = fixture(false); + fixture.order.setActualVolumeCbm(new BigDecimal("0.7479")); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("体积和体积重"); + + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString()); + } + @Test @DisplayName("驳回原因必填且状态推进为测量驳回") void shouldRequireRejectReason() { @@ -217,13 +252,15 @@ class MeasurementReviewServiceTest { order.setTotalPackageCount(5); order.setActualGrossWeightKg(new BigDecimal("1787.500")); order.setActualNetWeightKg(new BigDecimal("1747.500")); + order.setActualVolumeCbm(new BigDecimal("0.7480")); + order.setVolumeWeightKg(new BigDecimal("124.916")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(20L); measurement.setStatus("SUBMITTED"); - InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); - item.setPackageCount(5); - item.setTotalVolumeCbm(new BigDecimal("0.7480")); - item.setVolumeWeightKg(new BigDecimal("124.916")); + measurement.setTotalPackageCount(5); + measurement.setTotalVolumeCbm(new BigDecimal("0.7480")); + measurement.setVolumeWeightKg(new BigDecimal("124.916")); + InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916"); when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); @@ -236,6 +273,14 @@ class MeasurementReviewServiceTest { return new Fixture(service, repository, idempotency, order, measurement); } + private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(packages); + item.setTotalVolumeCbm(new BigDecimal(volume)); + item.setVolumeWeightKg(new BigDecimal(volumeWeight)); + return item; + } + private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) { InboundWeightResultEntity weight = new InboundWeightResultEntity(); weight.setRecognitionStatus("CONFIRMED"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 1dce4be86..b55a1d46c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -125,24 +125,35 @@ class TruckAppointmentServiceTest { @Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() { SaveRequest request = validRequest(); - when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0))); + TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0); + existing.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(88L)).thenReturn(existing); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + .isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑"); + verifyNoInteractions(oceanBookingDocuments); verify(repository, never()).insert(any()); verify(repository, never()).history(any()); + assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(existing.getVersion()).isZero(); } @Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() { SaveRequest request = validRequest(); + TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0); + concurrent.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent); doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'")) .when(repository).insert(any()); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + .isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑"); + verify(repository, times(2)).firstActiveForUpdate(88L); verify(repository, never()).history(any()); + assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(concurrent.getVersion()).isZero(); } @Test void createPreservesMaximumChineseRemarkAndBoundaryValues() { @@ -164,6 +175,31 @@ class TruckAppointmentServiceTest { assertThat(inserted.getValue().getPalletCount()).isZero(); assertThat(inserted.getValue().getGrossWeightKg()).isZero(); assertThat(inserted.getValue().getVolumeCbm()).isZero(); + assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB"); + } + + @Test void createAcceptsUsd() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("USD"); + when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45")); + + service.create(7L, "admin", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getCurrency()).isEqualTo("USD"); + assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45"); + } + + @Test void createRejectsUnsupportedCurrencyWithoutWriting() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("CNY"); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("RMB或USD"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); } @Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 5cd1c5046..08af231a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -58,6 +58,7 @@ public class LoadingCompletedConsumer { String containerNo = ContainerNumbers.requireCanonical(payload.containerNo()); String sourceContainerBookingId = sourceContainerBookingId(envelope, payload); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId()); if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); @@ -87,7 +88,8 @@ public class LoadingCompletedConsumer { for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); - createOrder(envelope, payload, source, orderNo, rawSource, containerNo, sourceContainerBookingId); + createOrder(envelope, payload, source, orderNo, rawSource, containerNo, + sourceContainerBookingId, owner); } inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); return payload.orders().size(); @@ -136,6 +138,33 @@ public class LoadingCompletedConsumer { return value.trim(); } + private OwnerSnapshot requireOwnerSnapshot(List orders, + String trustedCustomerId) { + String customerId = requiredOwnerValue(orders.get(0).customerId(), "customerId"); + String customerName = requiredOwnerValue(orders.get(0).customerName(), "customerName"); + if (!trustedCustomerId.equals(customerId)) { + throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + } + for (FirstLegLoadingPlanCompletedV1.Order order : orders) { + if (!customerId.equals(requiredOwnerValue(order.customerId(), "customerId")) + || !customerName.equals(requiredOwnerValue(order.customerName(), "customerName"))) { + throw new IllegalArgumentException("同一装柜完成事件的货主快照不一致"); + } + } + return new OwnerSnapshot(customerId, customerName); + } + + private String requiredOwnerValue(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("尾程事件缺少不可变货主快照 " + field); + } + String normalized = value.trim(); + if (normalized.length() > 128) { + throw new IllegalArgumentException("尾程事件货主快照过长 " + field); + } + return normalized; + } + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { Set orderCartonMarks = new HashSet<>(); @@ -186,7 +215,7 @@ public class LoadingCompletedConsumer { private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource, - String containerNo, String sourceContainerBookingId) { + String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) { String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); if(existing!=null) { @@ -196,11 +225,8 @@ public class LoadingCompletedConsumer { LastlegOrder order=new LastlegOrder(); String orderType = source.orderType(); String destinationType = destinationType(payload, source); - String customerId = source.customerId() == null || source.customerId().isBlank() - ? event.tenantId() : source.customerId(); - if (!event.tenantId().equals(customerId)) { - throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); - } + String customerId = owner.customerId(); + String customerName = owner.customerName(); List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets(source, rawSource) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { @@ -211,7 +237,7 @@ public class LoadingCompletedConsumer { order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); - order.setCustomerName(source.customerName()); + order.setCustomerName(customerName); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo); @@ -425,4 +451,6 @@ public class LoadingCompletedConsumer { } return result; } + + private record OwnerSnapshot(String customerId, String customerName) { } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 5465b15c9..94b6c5316 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -49,6 +49,18 @@ class LastlegOrderNoGeneratorTest { assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); } + @Test + void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() { + LocalDate firstDay = LocalDate.of(2026, 8, 27); + LocalDate nextDay = firstDay.plusDays(1); + when(sequenceMapper.currentValue()).thenReturn(42L, 1L); + + assertEquals("LMO-260827-0042", generator.next(firstDay)); + assertEquals("LMO-260828-0001", generator.next(nextDay)); + verify(sequenceMapper).nextValue(firstDay); + verify(sequenceMapper).nextValue(nextDay); + } + @Test void rejectsDailySequenceBeyondFourDigits() { when(sequenceMapper.currentValue()).thenReturn(10_000L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 77975a237..33e235730 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,7 +5,9 @@ class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } + @Test void oneContainerWith15InboundRowsSharesOneNewLmoNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^LMO-\\d{6}-\\d{4}$")); verify(sequences,times(1)).nextValue(any(java.time.LocalDate.class)); } + @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @@ -13,7 +15,7 @@ class LoadingCompletedConsumerTest { @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 000000000..fbfdad14e --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,39 @@ +# 后端脚本管理指引 + +## 适用范围与读取顺序 + +- 本文件适用于后端根 `scripts/` 及其子目录;服务自己的 `/scripts/` 继续受对应服务 `AGENTS.md` 约束。 +- 先用本文件判断脚本类别,再到 `README.md` 查询仓库级入口的用途、参数边界和副作用;Admin HTTP 模块继续读 `admin-test-env-modules/README.md`。 +- 不扫描 `target/`、`reports/`、`.logs/`、`node_modules/`、`.codex-*`、`output/` 或 `outputs/` 寻找业务脚本和验收事实。 + +## 脚本分类与入口 + +| 类别 | 正式入口 | 管理边界 | +|---|---|---| +| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 默认模块覆盖平台全链路;定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | +| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 客户工作台完整链路;`client-phase2-test-env-acceptance.mjs` 只是二期专项,不替代全量入口 | +| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | +| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | +| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | +| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | +| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | +| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | +| 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` | + +## 新增与修改规则 + +- 跨服务编排、全仓质量检查、测试环境验收和通用诊断放在根 `scripts/`;只理解单一服务源码或数据的脚本放在 `/scripts/`。 +- 可直接运行的脚本必须在 `README.md` 登记:用途、目标环境、是否写数据、凭据来源、报告位置、失败/重跑语义和清理边界。内部模块必须登记到 `admin-test-env-modules/README.md`,并按需同步总入口模块注册及依赖展开。 +- 文件命名使用稳定业务语义:离线 Node 合同以 `.test.mjs` 结尾;环境写入入口显式包含 `test-env` 或在 README 中标记目标环境;fixture/verify 必须区分准备写入和只读核验。 +- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。 +- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。 +- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。 +- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。 +- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。 + +## 最低验证 + +- 修改单个 Node 脚本先运行 `node --check