Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -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/`;服务专属脚本留在 `<service>/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 <name>`,模块名称和静态合同先查 `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 验收。
|
||||
|
||||
## 阶段文档入口
|
||||
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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;
|
||||
|
||||
@@ -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已取消');
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -59,14 +59,15 @@ public class FinanceExportProviderService {
|
||||
RECEIVABLE_STATISTICS);
|
||||
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
|
||||
private static final Set<String> 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<ExportColumn> 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<ExportColumn> LEDGER_COLUMNS = List.of(
|
||||
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
|
||||
|
||||
+18
@@ -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(
|
||||
|
||||
+4
-3
@@ -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 复制财务状态机。
|
||||
|
||||
@@ -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){ }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TruckAppointmentEntity>{
|
||||
@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<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> 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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
public PageResult<TruckAppointmentEntity> 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<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().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<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
|
||||
public java.util.List<TruckAppointmentEntity> 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<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
|
||||
}
|
||||
|
||||
@@ -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<InboundWeightResultEntity> 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());
|
||||
}
|
||||
|
||||
@@ -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<GroupSnapshot> 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,
|
||||
|
||||
@@ -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,
|
||||
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
|
||||
"审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@Service
|
||||
public class TruckAppointmentService {
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
private static final Set<String> 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);}
|
||||
|
||||
@@ -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 '触发状态或改约的业务动作',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
+3
-1
@@ -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<String,String> expected=Map.ofEntries(
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
|
||||
+51
@@ -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){
|
||||
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -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<OceanBookingDocumentEntity> 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);
|
||||
|
||||
+1
-1
@@ -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'")
|
||||
|
||||
+4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+70
-7
@@ -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<InboundWeightResultEntity> 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
|
||||
|
||||
@@ -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<TransactionStatus> 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");
|
||||
|
||||
+49
-4
@@ -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<InboundStatusHistoryEntity> 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");
|
||||
|
||||
+39
-3
@@ -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<TruckAppointmentEntity> 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() {
|
||||
|
||||
+36
-8
@@ -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<FirstLegLoadingPlanCompletedV1.Order> 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<String> 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<DropshipRequests.Pallet> 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) { }
|
||||
}
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
+4
-2
@@ -5,7 +5,9 @@ class LoadingCompletedConsumerTest {
|
||||
private final Map<String,String> 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<LastlegOrder> 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<LastlegOrder> 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<LastlegOrder> 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()); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 后端脚本管理指引
|
||||
|
||||
## 适用范围与读取顺序
|
||||
|
||||
- 本文件适用于后端根 `scripts/` 及其子目录;服务自己的 `<service>/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/`;只理解单一服务源码或数据的脚本放在 `<service>/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 <script>`;PowerShell 使用解析检查或该脚本声明的 `-DryRun`,Shell/Groovy 使用现有离线合同,不为验证触发部署。
|
||||
- 修改 Admin 模块时运行直接对应的 `*contract.test.mjs`;修改造数链时同时运行 `admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs` 等受影响合同。
|
||||
- 新增、移动或删除脚本后运行 `node --test scripts/script-documentation-contract.test.mjs`,确认目录已登记且所有直接入口都有说明。
|
||||
- 修改任一 `AGENTS.md` 后从后端根运行 `./scripts/agents-index-contract.ps1`;真实 HTTP、数据库、Nacos、Jenkins 和部署验证仍须当前轮明确授权。
|
||||
+40
-3
@@ -1,7 +1,44 @@
|
||||
# 全量 HTTP 验收脚本索引
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
|
||||
|
||||
进入本目录维护或运行脚本前先读 `scripts/AGENTS.md`;本文件是脚本入口、用途和副作用的唯一总索引,具体模块说明继续下沉到相邻 README。
|
||||
|
||||
## 验收环境变量文件
|
||||
|
||||
开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库:
|
||||
|
||||
| 环境 | 文件 |
|
||||
|---|---|
|
||||
| 开发环境 | `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env` |
|
||||
| 测试环境 | `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env` |
|
||||
|
||||
两份文件统一提供以下变量:
|
||||
|
||||
```text
|
||||
ADMIN_TEST_GATEWAY
|
||||
ADMIN_OVERSEAS_GATEWAY
|
||||
ADMIN_TEST_USERNAME
|
||||
ADMIN_TEST_PASSWORD
|
||||
MERCHANT_TEST_USERNAME
|
||||
MERCHANT_TEST_PASSWORD
|
||||
```
|
||||
|
||||
脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境:
|
||||
|
||||
```powershell
|
||||
# 开发环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
# 测试环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
# 测试环境批量待分柜造数
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/it-test-wait-split-bulk.mjs
|
||||
```
|
||||
|
||||
Client 全量、Client 二期、待审核订单和批量待分柜均复用这套变量,不再维护各自的 Gateway、管理员或客户账号变量。外部文件必须保留在仓库之外,不得复制到项目目录、报告或提交记录。
|
||||
|
||||
## 脚本目录归属
|
||||
|
||||
脚本按作用域放置,不要求全部集中到根 `scripts/`:
|
||||
@@ -64,7 +101,7 @@
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
|
||||
|
||||
@@ -74,7 +111,7 @@
|
||||
|
||||
## 固定客户待审核订单
|
||||
|
||||
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。可直接在文件顶部 `CONFIG` 中填写 `GATEWAY`、`USERNAME`、`PASSWORD` 和 `COUNT`;固定账号为 `17512095155`。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||
|
||||
```powershell
|
||||
node scripts/create-pending-review-orders.mjs
|
||||
|
||||
@@ -13,6 +13,33 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
|
||||
export function planA0LocationIsolation(locations, requiredSlots = 1,
|
||||
maxMutations = A0_MAX_LOCATION_MUTATIONS) {
|
||||
const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1)
|
||||
const limit = Math.max(0, Number.isInteger(Number(maxMutations)) ? Number(maxMutations) : 0)
|
||||
const unique = new Map()
|
||||
for (const location of Array.isArray(locations) ? locations : []) {
|
||||
const locationId = location?.id == null ? '' : String(location.id)
|
||||
if (!locationId || location?.sharedOverflow === true || unique.has(locationId)) continue
|
||||
unique.set(locationId, location)
|
||||
}
|
||||
const normal = [...unique.values()].sort((left, right) =>
|
||||
String(left?.locationCode || '').localeCompare(String(right?.locationCode || ''))
|
||||
|| String(left.id).localeCompare(String(right.id)))
|
||||
const remainingNormal = Math.max(0, slots - 1)
|
||||
const mutationsRequired = Math.max(0, normal.length - remainingNormal)
|
||||
return {
|
||||
enabled: mutationsRequired <= limit,
|
||||
requiredSlots: slots,
|
||||
normalCount: normal.length,
|
||||
expectedRemainingNormal: Math.min(normal.length, remainingNormal),
|
||||
mutationsRequired,
|
||||
targets: mutationsRequired <= limit ? normal.slice(0, mutationsRequired) : [],
|
||||
}
|
||||
}
|
||||
|
||||
const MODULE_DEPENDENCIES = new Map([
|
||||
['suppliers', ['merchant-qualification-fixture']],
|
||||
@@ -1236,15 +1263,17 @@ export class AcceptanceRun {
|
||||
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
|
||||
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
|
||||
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
|
||||
const disabledLocations = requireSharedOverflow
|
||||
? await this.disableAvailableLocationsForA0(warehouseId) : []
|
||||
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
|
||||
const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
|
||||
const isolation = requireSharedOverflow
|
||||
? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount)
|
||||
: { forced: false, disabled: [] }
|
||||
this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true
|
||||
try {
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
|
||||
} finally {
|
||||
if (requireSharedOverflow) {
|
||||
await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)
|
||||
await this.restoreAvailableLocationsAfterA0(warehouseId, isolation)
|
||||
}
|
||||
}
|
||||
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
||||
@@ -1430,24 +1459,60 @@ export class AcceptanceRun {
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
|
||||
async disableAvailableLocationsForA0(warehouseId) {
|
||||
async listAvailableLocationsForA0(warehouseId, module, options) {
|
||||
const locations = []
|
||||
const seen = new Set()
|
||||
let expectedTotal = null
|
||||
for (let pageNumber = 1; pageNumber <= 100; pageNumber += 1) {
|
||||
const response = await this.request('GET',
|
||||
`/api/platform/v1/locations?tenantId=0&page=${pageNumber}&size=${A0_LOCATION_PAGE_SIZE}&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`)
|
||||
const page = this.apiSuccess(module, `查询 A0 隔离前可用库位第 ${pageNumber} 页`, response,
|
||||
`page=${pageNumber}`, options)
|
||||
if (!page) return null
|
||||
const pageTotal = Number(page.total)
|
||||
const records = Array.isArray(page.records) ? page.records : []
|
||||
if (!Number.isInteger(pageTotal) || pageTotal < 0
|
||||
|| (expectedTotal !== null && pageTotal !== expectedTotal)) {
|
||||
this.assert(module, 'A0 隔离库位分页总数稳定', response, false,
|
||||
`page=${pageNumber}, firstTotal=${expectedTotal ?? 'missing'}, currentTotal=${page.total ?? 'missing'}`, options)
|
||||
return null
|
||||
}
|
||||
expectedTotal ??= pageTotal
|
||||
for (const location of records) {
|
||||
const locationId = location?.id == null ? '' : String(location.id)
|
||||
if (locationId && !seen.has(locationId)) {
|
||||
seen.add(locationId)
|
||||
locations.push(location)
|
||||
}
|
||||
}
|
||||
if (locations.length >= expectedTotal) break
|
||||
if (records.length === 0) break
|
||||
}
|
||||
const complete = locations.length === expectedTotal
|
||||
this.assert(module, 'A0 隔离库位分页完整且无重复', null, complete,
|
||||
`total=${expectedTotal ?? 'missing'}, uniqueRecords=${locations.length}`, options)
|
||||
return complete ? locations : null
|
||||
}
|
||||
|
||||
async disableAvailableLocationsForA0(warehouseId, requiredSlots = 1) {
|
||||
const module = 'phase3-loading-fixture'
|
||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||
if (!warehouseId) {
|
||||
this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options)
|
||||
return []
|
||||
return { forced: false, disabled: [] }
|
||||
}
|
||||
const locations = await this.listAvailableLocationsForA0(warehouseId, module, options)
|
||||
if (!locations) return { forced: false, disabled: [] }
|
||||
const isolation = planA0LocationIsolation(locations, requiredSlots)
|
||||
if (!isolation.enabled) {
|
||||
this.addStep(module, 'A0 大仓保护:不批量停用普通库位', null, true,
|
||||
`normal=${isolation.normalCount}, requiredSlots=${isolation.requiredSlots}, mutationsRequired=${isolation.mutationsRequired}, safetyLimit=${A0_MAX_LOCATION_MUTATIONS}`,
|
||||
{ scenario: options.scenario })
|
||||
return { forced: false, disabled: [], isolation }
|
||||
}
|
||||
const pageResponse = await this.request('GET',
|
||||
`/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`)
|
||||
const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
const locations = records.filter((item) => item.sharedOverflow !== true)
|
||||
this.assert(module, 'A0 隔离库位分页完整', pageResponse,
|
||||
Number(page?.total) === records.length && records.length < 200,
|
||||
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
||||
const disabled = []
|
||||
const skippedOccupied = []
|
||||
for (const location of locations) {
|
||||
for (const location of isolation.targets) {
|
||||
const response = await this.request('PATCH',
|
||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
{ status: 'DISABLED', version: Number(location.version) })
|
||||
@@ -1462,16 +1527,19 @@ export class AcceptanceRun {
|
||||
}
|
||||
const availableResponse = await this.request('GET',
|
||||
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
||||
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
||||
Array.isArray(available) && available.length === 0,
|
||||
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
||||
return disabled
|
||||
const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options)
|
||||
const remainingNormal = Array.isArray(available)
|
||||
? available.filter((item) => item?.sharedOverflow !== true) : null
|
||||
const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots
|
||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse, forced,
|
||||
`requiredSlots=${isolation.requiredSlots}, disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, remainingNormal=${Array.isArray(remainingNormal) ? remainingNormal.length : 'missing'}`, options)
|
||||
return { forced, disabled, isolation }
|
||||
}
|
||||
|
||||
async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) {
|
||||
async restoreAvailableLocationsAfterA0(warehouseId, result) {
|
||||
const module = 'phase3-loading-fixture'
|
||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||
const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : []
|
||||
for (const location of disabledLocations) {
|
||||
const response = await this.request('PATCH',
|
||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
@@ -1481,7 +1549,10 @@ export class AcceptanceRun {
|
||||
`locationId=${location.id}, restoreVersion=${location.version}`, options)
|
||||
}
|
||||
this.createdResources.phase3LoadingA0Isolation = {
|
||||
warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker,
|
||||
warehouseId, forced: result?.forced === true,
|
||||
normalCount: result?.isolation?.normalCount ?? null,
|
||||
mutationsRequired: result?.isolation?.mutationsRequired ?? 0,
|
||||
restored: disabledLocations.map((item) => item.id), marker: this.marker,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function run(context) {
|
||||
|---|---|---|
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
|
||||
@@ -37,6 +37,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁
|
||||
assert.match(source, /无 Finance export 权限拒绝创建/)
|
||||
assert.match(source, /Provider 失败信息可见/)
|
||||
assert.match(source, /9223372036854775806/)
|
||||
assert.match(source, /find\(\(item\) => item\?\.direction === 'RECEIVABLE'\)/)
|
||||
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
|
||||
assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([
|
||||
])
|
||||
|
||||
export const EXPORT_SPECS = Object.freeze({
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已付成本(CNY)', '待付(CNY)', '客户状态', '最近变动'],
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收客户款(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已线下支付供应商款(CNY)', '待付供应商款(CNY)', '客户状态', '最近变动'],
|
||||
FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'],
|
||||
FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '来源状态', '已生成对账单'],
|
||||
FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'],
|
||||
@@ -233,7 +233,10 @@ export async function run(context) {
|
||||
const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实')
|
||||
const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实')
|
||||
const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实')
|
||||
const feeRow = values(fee.data)[0]
|
||||
// PAYABLE rows are removed from the payable pool once an AP settlement owns
|
||||
// them. Use a receivable row so the export filter still has an authoritative
|
||||
// post-settlement match instead of asserting against an intentionally consumed row.
|
||||
const feeRow = values(fee.data).find((item) => item?.direction === 'RECEIVABLE') || values(fee.data)[0]
|
||||
const statementNo = statement.data?.statementNo
|
||||
const arSummary = ar.data?.summary || ar.data
|
||||
const apSummary = ap.data?.summary || ap.data
|
||||
|
||||
@@ -16,6 +16,7 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut
|
||||
})
|
||||
const financePermissions = [
|
||||
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
|
||||
'platform:finance:fee-source:edit',
|
||||
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
|
||||
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
|
||||
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
|
||||
@@ -61,6 +62,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
const calls = []
|
||||
const steps = []
|
||||
const coverageGaps = []
|
||||
const submittedSourceIds = new Set()
|
||||
const reviewedSourceIds = new Set()
|
||||
|
||||
function responseForStatement(statement) { return structuredClone(statement) }
|
||||
@@ -124,14 +126,24 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
|
||||
if (method === 'GET' && feeSourceDetailMatch) {
|
||||
const localId = feeSourceDetailMatch[1]
|
||||
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 })
|
||||
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED'
|
||||
: submittedSourceIds.has(localId) ? 'PENDING' : 'DRAFT',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false,
|
||||
operationVersion: reviewedSourceIds.has(localId) ? 2 : submittedSourceIds.has(localId) ? 1 : 0 })
|
||||
}
|
||||
const feeSourceSubmitMatch = requestPath.match(/\/fee-sources\/(\d+)\/submit$/)
|
||||
if (method === 'POST' && feeSourceSubmitMatch) {
|
||||
submittedSourceIds.add(feeSourceSubmitMatch[1])
|
||||
const submitted = { feeSourceId: feeSourceSubmitMatch[1], reviewStatus: 'PENDING',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
|
||||
if (idem) idempotency.set(idem, submitted)
|
||||
return ok(submitted)
|
||||
}
|
||||
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
|
||||
if (method === 'POST' && feeSourceReviewMatch) {
|
||||
reviewedSourceIds.add(feeSourceReviewMatch[1])
|
||||
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 2 }
|
||||
if (idem) idempotency.set(idem, reviewed)
|
||||
return ok(reviewed)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
||||
const REQUIRED_PERMISSIONS = [
|
||||
'platform:finance:customer-account:list',
|
||||
'platform:finance:customer-arap:list',
|
||||
'platform:finance:fee-source:edit',
|
||||
'platform:finance:fee-source:review',
|
||||
STATEMENT_LIST_PERMISSION,
|
||||
'platform:finance:reconciliation:create',
|
||||
@@ -172,6 +173,16 @@ async function approveFixtureSources(context, fixture) {
|
||||
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
|
||||
`feeSourceId=${source.feeSourceId}`, OPTIONS)
|
||||
if (!detail) return false
|
||||
if (detail.reviewStatus === 'DRAFT' || detail.reviewStatus === 'REJECTED') {
|
||||
const submitResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/submit`, {
|
||||
reason: context.marker, expectedVersion: Number(detail.operationVersion),
|
||||
}, `fee-source-submit-${source.feeSourceId}`)
|
||||
detail = context.apiSuccess(MODULE, '提交本轮 Finance 费用来源审核', submitResponse,
|
||||
`feeSourceId=${source.feeSourceId}`, OPTIONS)
|
||||
context.assert(MODULE, '费用来源提交后进入待审核', submitResponse,
|
||||
detail?.reviewStatus === 'PENDING' && detail?.sourceStatus === 'AVAILABLE',
|
||||
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
if (detail.reviewStatus === 'PENDING') {
|
||||
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
|
||||
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
|
||||
|
||||
@@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) {
|
||||
status: isNonBlank,
|
||||
carrierId: isPositiveId,
|
||||
carrierName: isNonBlank,
|
||||
payableAmount: isNonNegativeNumber,
|
||||
currency: (value) => value === 'RMB' || value === 'USD',
|
||||
attachmentCount: isNonNegativeNumber,
|
||||
})
|
||||
}
|
||||
|
||||
+6
-10
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
containerFieldGaps,
|
||||
customsCaseFieldGaps,
|
||||
loadingListFieldGaps,
|
||||
oceanBookingFieldGaps,
|
||||
outboundAggregateFieldGaps,
|
||||
@@ -33,14 +32,13 @@ test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段',
|
||||
}
|
||||
})
|
||||
|
||||
test('八类 Admin 列表均以左固定操作列开头', () => {
|
||||
test('现行 Admin 履约列表均以左固定操作列开头', () => {
|
||||
const sources = [
|
||||
['container-loading/pages/ContainersView.vue', 1],
|
||||
['loading-lists/components/LoadingListsView.vue', 2],
|
||||
['ocean-bookings/pages/OceanBookingsView.vue', 1],
|
||||
['port-schedules/pages/PortSchedulesView.vue', 1],
|
||||
['trucking/pages/TruckAppointmentsView.vue', 1],
|
||||
['customs/pages/CustomsCasesView.vue', 1],
|
||||
['outbound-dispatch/pages/OutboundDispatchesView.vue', 1],
|
||||
]
|
||||
for (const [relative, count] of sources) {
|
||||
@@ -56,8 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
|
||||
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'],
|
||||
'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'],
|
||||
'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
|
||||
}
|
||||
for (const [relative, fields] of Object.entries(expectations)) {
|
||||
@@ -73,7 +70,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'carrierName', 'plannedLoadingAt', 'containerNo']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java', ['record ContainerView', 'aggregateId', 'loadingPlanId', 'containerBookingId', 'planNo', 'cabinetNo', 'merchantStatus', 'coveredTaxRefundCount', 'documents']],
|
||||
['firstleg/src/main/resources/openapi/customs-v1.yaml', ['ContainerView:', 'WAITING_CUSTOMER_DOCUMENTS', 'READY_TO_START', 'DECLARING', 'COMPLETED']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']],
|
||||
]
|
||||
for (const [relative, fields] of sources) {
|
||||
@@ -89,14 +87,13 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
|
||||
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
|
||||
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
|
||||
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
|
||||
]
|
||||
for (const [validator, sample, options, missing] of samples) {
|
||||
assert.deepEqual(validator(sample, options), [])
|
||||
const invalid = structuredClone(sample)
|
||||
const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid
|
||||
const target = invalid.portSchedule?.schedule ?? invalid
|
||||
target[missing] = ''
|
||||
assert.ok(validator(invalid, options).includes(missing))
|
||||
}
|
||||
@@ -108,7 +105,6 @@ test('字段断言接入正式生产模块和默认全量入口', () => {
|
||||
'ocean-bookings.mjs': ['oceanBookingFieldGaps'],
|
||||
'phase3-port-schedules.mjs': ['portScheduleFieldGaps'],
|
||||
'truck-appointments.mjs': ['truckAppointmentFieldGaps'],
|
||||
'phase3-customs.mjs': ['customsCaseFieldGaps'],
|
||||
'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'],
|
||||
}
|
||||
for (const [file, tokens] of Object.entries(consumers)) {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
const labelTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java')
|
||||
const measurementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java')
|
||||
const cartonTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java')
|
||||
const reviewTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java')
|
||||
|
||||
test('measurement acceptance scans the stable box code frozen by first print', () => {
|
||||
assert.match(source, /const boxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-1`/)
|
||||
@@ -32,3 +37,37 @@ test('measurement fixture derives pallet resources from confirmed image count',
|
||||
test('volume ceiling removes binary floating point noise before scaling', () => {
|
||||
assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/)
|
||||
})
|
||||
|
||||
test('demo package freezes repeated label printing and Mojia dimension replacement', () => {
|
||||
assert.match(labelTests, /repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation/)
|
||||
assert.match(labelTests, /changedCargoKeepsTheFirstFrozenBoxCodes/)
|
||||
assert.match(cartonTests, /repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted/)
|
||||
assert.match(source, /增量 fixture 形成动态真实独立货物测量明细/)
|
||||
})
|
||||
|
||||
test('demo package covers OCR failure, manual confirmation, aggregation and stale versions', () => {
|
||||
assert.match(source, /OCR无有效重量时转人工确认/)
|
||||
assert.match(source, /称重旧版本确认被拒绝且不覆盖已确认结果/)
|
||||
assert.match(source, /图片张数决定托数且净重汇总已落地/)
|
||||
assert.match(measurementTests, /ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed/)
|
||||
assert.match(measurementTests, /confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight/)
|
||||
assert.match(measurementTests, /confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent/)
|
||||
})
|
||||
|
||||
test('demo package keeps pallet pages bounded and historical snapshots read-only', () => {
|
||||
assert.match(source, /weight-pallet-options[^\n]+page=1&size=20&keyword=/)
|
||||
assert.match(source, /records\.length <= 20/)
|
||||
assert.match(measurementTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/)
|
||||
})
|
||||
|
||||
test('approval freezes measurement and requires quantity weight and volume conservation', () => {
|
||||
assert.match(source, /审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖/)
|
||||
assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/)
|
||||
assert.match(reviewTests, /shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch/)
|
||||
assert.match(reviewTests, /shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve/)
|
||||
assert.match(source, /actualPackageCount[^\n]+originalPackageCount/)
|
||||
assert.match(source, /actualGrossWeightKg[^\n]+weightEvidence\.grossWeightKg/)
|
||||
assert.match(source, /actualNetWeightKg[^\n]+weightEvidence\.netWeightKg/)
|
||||
assert.match(source, /actualVolumeCbm[^\n]+totalVolume/)
|
||||
assert.match(source, /volumeWeightKg[^\n]+volumeWeight/)
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts =
|
||||
return { response, measurement, attempts }
|
||||
}
|
||||
|
||||
async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) {
|
||||
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
|
||||
if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null
|
||||
|
||||
const resources = []
|
||||
@@ -92,6 +92,20 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa
|
||||
if (!candidates) return null
|
||||
const palletIds = randomlyReusePalletIds(candidates, palletCount,
|
||||
context.context?.waitSplitVariant?.randomValue)
|
||||
const candidateKeyword = candidates.find((item) => item.id === palletIds[0])?.code || ''
|
||||
const candidateResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-pallet-options?${query(tenantId)}&page=1&size=20&keyword=${encodeURIComponent(candidateKeyword)}`)
|
||||
const candidatePage = context.apiSuccess(MODULE, '按关键词分页查询当前入库仓称重托盘候选', candidateResponse,
|
||||
`warehouseId=${warehouseId}, keyword=${candidateKeyword}`, OPTIONS)
|
||||
const pageControlled = Array.isArray(candidatePage?.records) && candidatePage.records.length <= 20
|
||||
&& Number(candidatePage?.page) === 1 && Number(candidatePage?.size) === 20
|
||||
&& Number(candidatePage?.total) >= candidatePage.records.length
|
||||
&& candidatePage.records.some((item) => asId(item?.palletId) === palletIds[0])
|
||||
context.assert(MODULE, '托盘候选保持服务端关键词分页且不返回无界数组', candidateResponse,
|
||||
pageControlled,
|
||||
`records=${candidatePage?.records?.length ?? 'missing'}, page=${candidatePage?.page ?? 'missing'}, size=${candidatePage?.size ?? 'missing'}, total=${candidatePage?.total ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!pageControlled) return null
|
||||
for (let index = 1; index <= palletCount; index += 1) {
|
||||
const palletId = palletIds[index - 1]
|
||||
context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null,
|
||||
@@ -134,6 +148,10 @@ async function createConfirmedWeightEvidence(context, {
|
||||
const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`,
|
||||
createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
||||
if (!created?.id) return null
|
||||
context.assert(MODULE, `OCR无有效重量时转人工确认 ${index + 1}/${palletIds.length}`, createResponse,
|
||||
created.recognitionStatus === 'NEEDS_MANUAL' && created.ocrWeightValue == null,
|
||||
`recognitionStatus=${created.recognitionStatus || 'missing'}, ocrWeightValue=${created.ocrWeightValue ?? 'null'}`,
|
||||
OPTIONS)
|
||||
|
||||
const grossWeight = Number(grossWeightPerPalletKg) + index * 5
|
||||
const confirmResponse = await context.request('POST',
|
||||
@@ -148,6 +166,16 @@ async function createConfirmedWeightEvidence(context, {
|
||||
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
|
||||
return null
|
||||
}
|
||||
const staleConfirmResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
||||
version: Number(created.version), adjustedWeightValue: grossWeight + 1,
|
||||
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 旧版本冲突验证`,
|
||||
})
|
||||
context.assert(MODULE, `称重旧版本确认被拒绝且不覆盖已确认结果 ${index + 1}/${palletIds.length}`,
|
||||
staleConfirmResponse,
|
||||
staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409',
|
||||
`HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`,
|
||||
OPTIONS)
|
||||
confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg })
|
||||
}
|
||||
|
||||
@@ -161,7 +189,9 @@ async function createConfirmedWeightEvidence(context, {
|
||||
const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0)
|
||||
const persisted = confirmedRows.length === palletIds.length
|
||||
&& confirmedResults.every((expected) => confirmedRows.some((row) =>
|
||||
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId))
|
||||
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId
|
||||
&& String(row?.palletCode || '').trim() && String(row?.palletType || '').trim()
|
||||
&& Number.isFinite(Number(row?.palletTareWeightKg))))
|
||||
&& closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001)
|
||||
&& closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001)
|
||||
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
|
||||
@@ -533,7 +563,7 @@ export async function run(context) {
|
||||
|
||||
const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1))
|
||||
const warehouseResourcesReady = await provisionWarehouseResources(context, {
|
||||
inboundId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
})
|
||||
context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse,
|
||||
Boolean(warehouseResourcesReady),
|
||||
@@ -606,6 +636,15 @@ export async function run(context) {
|
||||
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
|
||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const approvedScanResponse = await multipartRequest(context, scanPath, {
|
||||
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-AFTER-APPROVAL',
|
||||
measuredAt: new Date().toISOString().slice(0, 19),
|
||||
})
|
||||
context.assert(MODULE, '审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖', approvedScanResponse,
|
||||
approvedScanResponse.http === 200 && String(approvedScanResponse.body?.code) === 'A0409',
|
||||
`HTTP=${approvedScanResponse.http}, code=${approvedScanResponse.body?.code ?? 'missing'}, requestId=${approvedScanResponse.requestId || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.inboundMeasurement = {
|
||||
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
|
||||
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
||||
|
||||
@@ -8,7 +8,7 @@ const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.met
|
||||
const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8')
|
||||
const warehouseLocationResources = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java', import.meta.url), 'utf8')
|
||||
|
||||
test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => {
|
||||
test('Admin 通用单票支持到1000箱且八千票专项入口限制为300箱', () => {
|
||||
assert.match(acceptance, /--wait-split-package-count <n>/)
|
||||
assert.match(acceptance, /wait-split-package-count必须是1-1000的整数或0/)
|
||||
assert.match(acceptance, /packageCounts\.push\(remaining\)/)
|
||||
@@ -16,6 +16,7 @@ test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => {
|
||||
assert.match(orders, /packageCounts\[index\] \|\| packageCountPerSku/)
|
||||
assert.match(measurement, /detailPackageCounts\.reduce\(\(sum, count\) => sum \+ count, 0\)/)
|
||||
assert.match(measurement, /detailPackageCounts\.slice\(0, detailIndex\)/)
|
||||
assert.match(runner, /boxMax > 300/)
|
||||
})
|
||||
|
||||
test('随机线路只固定义乌仓并从客户真实可计价候选选择', () => {
|
||||
|
||||
@@ -42,7 +42,11 @@ test('backend never appends a generic STATUS_UPDATE and push replay is read-only
|
||||
assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s)
|
||||
assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/)
|
||||
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/)
|
||||
assert.match(smartLoadingTest, /exactPushReplayIsReadOnly[\s\S]{0,1000}never\(\)\.insert/)
|
||||
const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()')
|
||||
const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10)
|
||||
const replayBlock = smartLoadingTest.slice(replayStart, replayEnd)
|
||||
assert.notEqual(replayStart, -1)
|
||||
assert.match(replayBlock, /never\(\)\)\.insert/)
|
||||
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
|
||||
})
|
||||
|
||||
@@ -50,7 +54,7 @@ test('operationId is projected end-to-end for identity-based frontend deduplicat
|
||||
assert.match(loadingDtos, /record History\([\s\S]{0,180}String operationId/)
|
||||
assert.match(loadingRepository, /new History\([\s\S]{0,180}getOperationId\(\)/)
|
||||
assert.match(openapi, /OperationHistory:[\s\S]{0,500}operationId:/)
|
||||
assert.match(adminApi, /interface LoadingHistory[\s\S]{0,260}operationId:\s*string/)
|
||||
assert.match(adminApi, /interface LoadingHistory[\s\S]*?operationId:\s*string/)
|
||||
assert.match(adminApi, /normalizeHistory[\s\S]{0,260}operationId:/)
|
||||
})
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) {
|
||||
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
|
||||
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
|
||||
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
}
|
||||
|
||||
async function createTruck(context, oceanBooking, index) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('order demo payload covers multi-box multi-SKU images and both destination families', () => {
|
||||
for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) {
|
||||
assert.match(source, new RegExp(`boxType: '${boxType}'`))
|
||||
}
|
||||
assert.match(source, /cargoes: selectedBoxes\.map/)
|
||||
assert.match(source, /productImageFileId/)
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'FBA' } }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'OVERSEAS' } }), 'OVERSEAS')
|
||||
})
|
||||
|
||||
test('create and submit lifecycle covers replay payload conflict and zero drift', () => {
|
||||
assert.match(source, /Client 创建订单幂等重放/)
|
||||
assert.match(source, /Client 创建订单同键异载荷返回A0409/)
|
||||
assert.match(source, /创建同键异载荷失败后订单全事实零漂移/)
|
||||
assert.match(source, /Client 提交订单同键同载荷精确重放/)
|
||||
assert.match(source, /已提交订单使用新键重复提交返回A0409/)
|
||||
assert.match(source, /重复提交失败后订单状态货物与历史零漂移/)
|
||||
assert.match(source, /orderPersistenceFacts\(draftAfterConflict\) === orderPersistenceFacts\(draft\)/)
|
||||
assert.match(source, /orderPersistenceFacts\(submittedAfterFailure\) === orderPersistenceFacts\(submitted\)/)
|
||||
})
|
||||
|
||||
test('persistence fact comparison includes order cargo parties notes and history', () => {
|
||||
const detail = {
|
||||
order: { id: '1', status: 'DRAFT' }, cargoes: [{ skuNo: 'SKU-1' }],
|
||||
parties: [{ role: 'SENDER' }], internalNotes: [{ content: 'note' }],
|
||||
statusHistory: [{ toStatus: 'DRAFT' }],
|
||||
}
|
||||
const baseline = orderPersistenceFacts(detail)
|
||||
assert.equal(orderPersistenceFacts(structuredClone(detail)), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, order: { ...detail.order, status: 'SUBMITTED' } }), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, cargoes: [{ skuNo: 'SKU-2' }] }), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, statusHistory: [...detail.statusHistory, { toStatus: 'SUBMITTED' }] }), baseline)
|
||||
})
|
||||
|
||||
test('approval convergence is a hard WAIT_MEASURE prerequisite without coverage downgrade', () => {
|
||||
assert.match(source, /审核后入库单与履约任务异步收敛/)
|
||||
assert.match(source, /MEASURABLE_STATUSES/)
|
||||
assert.match(source, /throw new Error\('订单审核后的入库单与履约任务未在30秒内收敛/)
|
||||
assert.doesNotMatch(source, /审核后入库单与履约任务异步收敛[\s\S]{0,300}addCoverageGap/)
|
||||
})
|
||||
@@ -25,6 +25,15 @@ function rotate(values, offset) {
|
||||
return [...values.slice(shift), ...values.slice(0, shift)]
|
||||
}
|
||||
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
|
||||
export function orderPersistenceFacts(detail) {
|
||||
return JSON.stringify({
|
||||
order: detail?.order ?? null,
|
||||
cargoes: Array.isArray(detail?.cargoes) ? detail.cargoes : [],
|
||||
parties: Array.isArray(detail?.parties) ? detail.parties : [],
|
||||
internalNotes: Array.isArray(detail?.internalNotes) ? detail.internalNotes : [],
|
||||
statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [],
|
||||
})
|
||||
}
|
||||
export function requestedOrderDestinationType(context) {
|
||||
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
||||
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
||||
@@ -547,13 +556,41 @@ export async function run(context) {
|
||||
assertCargoContract(context, draftResponse, draft?.cargoes, payload)
|
||||
context.createdResources.orders.orderNo = orderNo
|
||||
|
||||
const createConflictPayload = {
|
||||
...payload,
|
||||
customerRemark: `${payload.customerRemark} 同键异载荷`,
|
||||
}
|
||||
const createConflictResponse = await request(context, 'POST', createPath, createConflictPayload, merchantToken,
|
||||
{ 'Idempotency-Key': key(context, 'client-create') })
|
||||
context.assert(MODULE, 'Client 创建订单同键异载荷返回A0409', createConflictResponse,
|
||||
createConflictResponse.http === 200 && String(createConflictResponse.body?.code) === 'A0409',
|
||||
`http=${createConflictResponse.http}, code=${createConflictResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||
const draftAfterConflictResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
||||
const draftAfterConflict = context.apiSuccess(MODULE, 'Client 回查创建冲突后的订单草稿', draftAfterConflictResponse)
|
||||
context.assert(MODULE, '创建同键异载荷失败后订单全事实零漂移', draftAfterConflictResponse,
|
||||
orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft),
|
||||
`orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||
|
||||
const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined,
|
||||
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') })
|
||||
context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`)
|
||||
const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined,
|
||||
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') })
|
||||
context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`)
|
||||
const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
||||
const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse)
|
||||
context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse,
|
||||
submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`)
|
||||
const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined,
|
||||
merchantToken, { 'Idempotency-Key': key(context, 'client-submit-new-key') })
|
||||
context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse,
|
||||
duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409',
|
||||
`http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||
const submittedAfterFailureResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
|
||||
const submittedAfterFailure = context.apiSuccess(MODULE, 'Client 回查重复提交失败后的订单', submittedAfterFailureResponse)
|
||||
context.assert(MODULE, '重复提交失败后订单状态货物与历史零漂移', submittedAfterFailureResponse,
|
||||
orderPersistenceFacts(submittedAfterFailure) === orderPersistenceFacts(submitted),
|
||||
`orderId=${orderId}, status=${submittedAfterFailure?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||
|
||||
const unifiedListResponse = await context.request('GET',
|
||||
`/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`)
|
||||
|
||||
+20
-17
@@ -8,6 +8,7 @@ const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstle
|
||||
const completionCommand = read('../../firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java')
|
||||
const completionWorker = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java')
|
||||
const loadingPickTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java')
|
||||
const completionWorkerTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java')
|
||||
const settlementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java')
|
||||
const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql')
|
||||
const finalizeHttp = read('phase3-loading-finalize.mjs')
|
||||
@@ -15,8 +16,6 @@ const pdaPage = read('../../../apps/pda-uniapp/pages/picking/picking.vue')
|
||||
const pdaCompletionState = read('../../../apps/pda-uniapp/utils/loading-completion-state.js')
|
||||
const pdaCompletionTests = read('../../../apps/pda-uniapp/tests/loading-completion-state.test.mjs')
|
||||
|
||||
const RECOVERY_TASK_ID = '2092817774453780481'
|
||||
|
||||
function retryDecision({ operationStatus, sameRequest, finalTaskStatus }) {
|
||||
if (!sameRequest) return 'CONFLICT'
|
||||
if (operationStatus === 'PROCESSING') return 'PROCESSING'
|
||||
@@ -74,7 +73,8 @@ test('durable worker processes inbound settlements incrementally with retry back
|
||||
assert.match(completionWorker, /15L <<|nextRetryAt|retryAt/)
|
||||
assert.match(completionWorker, /"FAILED"\s*:\s*"RETRYABLE"/)
|
||||
assert.match(loadingPickService, /checkpointAtLeast[\s\S]{0,1800}SETTLED/)
|
||||
assert.match(loadingPickTests, /(?:transient|瞬时)[\s\S]{0,1500}(?:backoff|retry|退避)/i)
|
||||
assert.match(completionWorkerTests, /transientFailureIsReleasedForBackoffRetry/)
|
||||
assert.match(completionWorkerTests, /"RETRYABLE"/)
|
||||
assert.match(loadingPickTests, /FAILED[\s\S]{0,1500}(?:same operationId|同operationId|resume|恢复)/i)
|
||||
})
|
||||
|
||||
@@ -89,27 +89,30 @@ test('a different operationId is rejected while the completion command is proces
|
||||
})
|
||||
|
||||
test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => {
|
||||
assert.match(settlementTests, /15|fifteen/i)
|
||||
assert.match(settlementTests, /(?:partial success|部分成功|local rollback|本地回滚)/i)
|
||||
assert.match(settlementTests, /(?:settlementTarget|COMPLETED|replaySettlement)/)
|
||||
assert.match(loadingPickTests, /(?:resume|recover|reconcile|恢复|核平)[\s\S]{0,2000}COMPLETE_LOADING/i)
|
||||
assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/)
|
||||
assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/)
|
||||
assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/)
|
||||
assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/)
|
||||
assert.match(settlementTests, /targetBusinessId\(\) == 999L/)
|
||||
assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/)
|
||||
})
|
||||
|
||||
test('recovery fixture preserves the observed fifteen-settlement partial success', () => {
|
||||
assert.equal(RECOVERY_TASK_ID, '2092817774453780481')
|
||||
assert.match(finalizeHttp, new RegExp(RECOVERY_TASK_ID))
|
||||
assert.match(finalizeHttp, /15[\s\S]{0,600}(?:Warehouse|settlement)/i)
|
||||
assert.match(finalizeHttp, /(?:LOADING|v1)[\s\S]{0,600}(?:恢复|recovery|resume)/i)
|
||||
test('recovery contract is fixture-independent and uses durable checkpoints', () => {
|
||||
assert.doesNotMatch(finalizeHttp, /2092817774453780481/)
|
||||
assert.match(loadingPickService, /checkpointAtLeast\(checkpoint, "SETTLED"\)/)
|
||||
assert.match(loadingPickService, /checkpointAtLeast\(checkpoint, "INBOUND_COMPLETED"\)/)
|
||||
assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/)
|
||||
})
|
||||
|
||||
test('final acceptance reconciles task allocation reservation occupancy and one completion audit', () => {
|
||||
for (const fact of ['task', 'allocation', 'reservation', 'occupancy']) {
|
||||
test('final acceptance reconciles terminal task and one completion audit after owner checkpoints', () => {
|
||||
for (const fact of ['task', 'plan', 'inbound', 'operation']) {
|
||||
assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing final ${fact} reconciliation`)
|
||||
}
|
||||
assert.match(finalizeHttp, /COMPLETE_LOADING/)
|
||||
assert.match(finalizeHttp, /(?:length === 1|恰有一次|只写一条)/)
|
||||
assert.match(finalizeHttp, /(?:PROCESSING|处理中)/)
|
||||
assert.match(finalizeHttp, /(?:elapsed|latency|耗时|毫秒|ms)/i)
|
||||
assert.match(finalizeHttp, /concurrentCompleteOperations\.length === 1/)
|
||||
assert.match(finalizeHttp, /异步完成命令收敛到终态/)
|
||||
assert.match(loadingPickService, /completionTransaction\.executeWithoutResult/)
|
||||
assert.match(loadingPickService, /private void finalizeCompletion/)
|
||||
})
|
||||
|
||||
test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => {
|
||||
|
||||
+9
-2
@@ -11,6 +11,7 @@ const locationTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/s
|
||||
const settlementService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java')
|
||||
const settlementTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java')
|
||||
const finalizeHttp = read('phase3-loading-finalize.mjs')
|
||||
const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java')
|
||||
|
||||
function releaseDecision({ sharedOverflow, locationStatus, activeOccupanciesAfterRelease }) {
|
||||
if (sharedOverflow) return 'SHARED_STATUS_UNCHANGED'
|
||||
@@ -63,10 +64,16 @@ test('same settlement key replays without a second release or transfer', () => {
|
||||
assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.transferToChildByReservation/)
|
||||
})
|
||||
|
||||
test('HTTP failure contract proves task plan allocation location history outbox operation zero drift', () => {
|
||||
for (const fact of ['task', 'plan', 'inbound', 'allocation', 'location', 'history', 'outbox', 'operation']) {
|
||||
test('acceptance failure is zero-write before worker and final local convergence is transactional', () => {
|
||||
for (const fact of ['task', 'plan', 'inbound', 'operation']) {
|
||||
assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing ${fact} failure baseline`)
|
||||
}
|
||||
assert.match(finalizeHttp, /A0409/)
|
||||
assert.match(finalizeHttp, /零写入|零漂移/)
|
||||
assert.match(loadingPickService, /completionTransaction\.executeWithoutResult/)
|
||||
const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'),
|
||||
loadingPickService.indexOf('private void requireColoadConservation'))
|
||||
for (const fact of ['COMPLETE_LOADING', 'finishFromPda', 'publishCompletedEvent', 'completeAccepted']) {
|
||||
assert.match(finalizer, new RegExp(fact), `missing transactional ${fact}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { planA0LocationIsolation } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
function locations(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: String(index + 1), locationCode: `L-${String(index + 1).padStart(4, '0')}`,
|
||||
sharedOverflow: false, version: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
test('large warehouse is never selected for default mass disable', () => {
|
||||
const plan = planA0LocationIsolation(locations(614), 1)
|
||||
assert.equal(plan.enabled, false)
|
||||
assert.equal(plan.mutationsRequired, 614)
|
||||
assert.deepEqual(plan.targets, [])
|
||||
})
|
||||
|
||||
test('minimum mutations leave fewer normal locations than required pallets', () => {
|
||||
const plan = planA0LocationIsolation(locations(3), 3)
|
||||
assert.equal(plan.enabled, true)
|
||||
assert.equal(plan.mutationsRequired, 1)
|
||||
assert.equal(plan.targets.length, 1)
|
||||
assert.equal(plan.expectedRemainingNormal, 2)
|
||||
assert.ok(plan.expectedRemainingNormal < plan.requiredSlots)
|
||||
})
|
||||
|
||||
test('natural shortage proves A0 fallback without any location mutation', () => {
|
||||
const plan = planA0LocationIsolation(locations(1), 2)
|
||||
assert.equal(plan.enabled, true)
|
||||
assert.equal(plan.mutationsRequired, 0)
|
||||
assert.deepEqual(plan.targets, [])
|
||||
assert.ok(plan.normalCount < plan.requiredSlots)
|
||||
})
|
||||
|
||||
test('shared A0 and duplicate rows never inflate the normal location plan', () => {
|
||||
const normal = locations(2)
|
||||
const plan = planA0LocationIsolation([
|
||||
...normal, normal[0], { id: 'A0', locationCode: 'A0', sharedOverflow: true, version: 0 },
|
||||
], 2)
|
||||
assert.equal(plan.normalCount, 2)
|
||||
assert.equal(plan.mutationsRequired, 1)
|
||||
assert.deepEqual(plan.targets.map((item) => item.id), ['1'])
|
||||
})
|
||||
@@ -50,6 +50,18 @@ function blocker(context, name, evidence) {
|
||||
}
|
||||
|
||||
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
|
||||
export function validContainerNo(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Z]{3}[UJZ][0-9]{7}$/.test(value)) return false
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
const number = code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]
|
||||
sum += number * (2 ** index)
|
||||
}
|
||||
return Number(value[10]) === (sum % 11) % 10
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
|
||||
@@ -84,6 +96,7 @@ export async function run(context) {
|
||||
const task = item?.query
|
||||
return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId
|
||||
&& id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo
|
||||
&& task?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo)
|
||||
&& task?.triggerSource === 'OUTBOUND_CONFIRM'
|
||||
})
|
||||
context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse,
|
||||
@@ -104,6 +117,8 @@ export async function run(context) {
|
||||
&& item.actualAt != null && item.estimatedAt == null)
|
||||
context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse,
|
||||
id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id)
|
||||
&& detail?.query?.query?.trackingType === 'CONTAINER_NO'
|
||||
&& validContainerNo(detail?.query?.query?.trackingNo)
|
||||
&& departed.length >= 1 && departed.every((item) => validLongId(item.id)
|
||||
&& item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode
|
||||
&& typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)),
|
||||
|
||||
@@ -4,21 +4,21 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { currentOutboundFixture, ownsCarrierQuery, validContainerNo } from './phase3-carrier-schedule-queries.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8')
|
||||
|
||||
function context(marker = 'RUN') {
|
||||
return { marker, createdResources: {
|
||||
phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' },
|
||||
phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'ZCSU7881970' },
|
||||
phase3OutboundDispatch: { marker, tenantId: '10001', containerBookingId: '30001', confirmedStatus: 'WAITING_FOR_LOADING' },
|
||||
} }
|
||||
}
|
||||
|
||||
test('outbound fixture requires exact current marker and matching loading/outbound container', () => {
|
||||
assert.deepEqual(currentOutboundFixture(context()), {
|
||||
marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'TSTU1234567',
|
||||
marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'ZCSU7881970',
|
||||
})
|
||||
const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD'
|
||||
assert.equal(currentOutboundFixture(old), null)
|
||||
@@ -26,6 +26,13 @@ test('outbound fixture requires exact current marker and matching loading/outbou
|
||||
assert.equal(currentOutboundFixture(mismatch), null)
|
||||
})
|
||||
|
||||
test('carrier fixture only accepts normalized ISO 6346 physical container numbers', () => {
|
||||
assert.equal(validContainerNo('ZCSU7881970'), true)
|
||||
assert.equal(validContainerNo('zcsu7881970'), false)
|
||||
assert.equal(validContainerNo('ZCSU7881971'), false)
|
||||
assert.equal(validContainerNo('CAB-260901-0001'), false)
|
||||
})
|
||||
|
||||
test('carrier query ownership requires explicit outbound-confirm marker registration', () => {
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true,
|
||||
triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true)
|
||||
@@ -41,6 +48,8 @@ test('phase3 script only discovers the current outbound task and blocks missing
|
||||
assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/)
|
||||
assert.match(source, /deploymentBlocker:\s*true/)
|
||||
assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/)
|
||||
assert.match(source, /trackingType === 'CONTAINER_NO'/)
|
||||
assert.match(source, /validContainerNo\(task\?\.trackingNo\)/)
|
||||
assert.match(source, /DEPARTED_WAREHOUSE/)
|
||||
assert.match(source, /providerEventId/)
|
||||
assert.match(source, /\^\\d\{16,/)
|
||||
|
||||
@@ -22,6 +22,21 @@ function failure(response) {
|
||||
}
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
|
||||
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
|
||||
let response = null
|
||||
let task = null
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
response = await context.request('GET',
|
||||
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
task = response?.http === 200 && codeOf(response) === '200' ? response.body?.data : null
|
||||
if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') {
|
||||
return { response, task, attempts: attempt }
|
||||
}
|
||||
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
return { response, task, attempts }
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
@@ -43,15 +58,21 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) {
|
||||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
|
||||
async function uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo) {
|
||||
const path = `${PDA}/tasks/${taskId}/evidence`
|
||||
const form = new FormData()
|
||||
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
||||
form.append('warehouseId', String(warehouseId))
|
||||
form.append('expectedVersion', '0')
|
||||
form.append('evidenceType', evidenceType)
|
||||
if (evidenceType === 'SEAL') form.append('sealNo', sealNo)
|
||||
form.append('remark', context.marker)
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
@@ -66,14 +87,6 @@ async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
|
||||
}
|
||||
}
|
||||
|
||||
async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) {
|
||||
return request(context, 'POST',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png',
|
||||
sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) })
|
||||
}
|
||||
|
||||
export function selectContainerLoadingFixture(context) {
|
||||
const candidates = [
|
||||
context.createdResources?.orchestrationFixtures?.phase3Container,
|
||||
@@ -721,30 +734,22 @@ export async function run(context) {
|
||||
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||
const sealNo = `SEAL-${suffix}`.slice(0, 64)
|
||||
for (const evidenceType of evidenceTypes) {
|
||||
const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
|
||||
const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse,
|
||||
const uploadResponse = await uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo)
|
||||
const bound = context.apiSuccess(MODULE, `上传并绑定柜次 ${evidenceType} 真实证据文件`, uploadResponse,
|
||||
responseEvidence(uploadResponse), OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse,
|
||||
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}`
|
||||
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0,
|
||||
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS)
|
||||
if (!fileId) return
|
||||
|
||||
const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo)
|
||||
const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse,
|
||||
responseEvidence(bindResponse), OPTIONS)
|
||||
const boundItem = bound?.evidences?.evidences?.find?.((item) =>
|
||||
item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID')
|
||||
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse,
|
||||
bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id)
|
||||
const boundItem = bound?.evidences?.find?.((item) => item.evidenceType === evidenceType && item.status !== 'VOID')
|
||||
const evidenceId = id(boundItem?.evidenceId)
|
||||
const fileId = id(boundItem?.fileId)
|
||||
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, uploadResponse,
|
||||
id(bound?.taskId) === taskId && id(bound?.containerBookingId) === containerId
|
||||
&& Boolean(evidenceId) && Boolean(fileId)
|
||||
&& boundItem?.remark === context.marker
|
||||
&& (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo),
|
||||
`mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
|
||||
if (!boundItem?.id) return
|
||||
`taskId=${id(bound?.taskId) || 'missing'}, containerBookingId=${id(bound?.containerBookingId) || 'missing'}, evidenceId=${evidenceId || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
|
||||
if (!evidenceId) return
|
||||
|
||||
const downloadResponse = await context.requestBinary('GET',
|
||||
`/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
|
||||
`${PDA}/tasks/${taskId}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse,
|
||||
downloadResponse.http === 200 && downloadResponse.bytes.length > 0
|
||||
&& downloadResponse.contentType.toLowerCase().includes('image/png'),
|
||||
@@ -752,7 +757,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const evidenceListResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
`${PDA}/tasks/${taskId}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse,
|
||||
'', OPTIONS)
|
||||
const activeEvidence = Array.isArray(evidenceList?.evidences)
|
||||
@@ -771,13 +776,19 @@ export async function run(context) {
|
||||
remark: context.marker,
|
||||
}
|
||||
const completeResponse = await request(context, 'POST', completePath, completePayload)
|
||||
const completed = context.apiSuccess(MODULE, 'PDA 人工确认装柜完成', completeResponse, '', OPTIONS)
|
||||
const acceptedCompletion = context.apiSuccess(MODULE, 'PDA 提交人工确认装柜完成', completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜完成命令已接受并进入处理或终态', completeResponse,
|
||||
['COMPLETING', 'COMPLETED'].includes(acceptedCompletion?.taskStatus),
|
||||
`taskStatus=${acceptedCompletion?.taskStatus || 'missing'}, workflow=${acceptedCompletion?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
const completion = await waitForCompletedTask(context, taskId, warehouseId)
|
||||
const completed = context.apiSuccess(MODULE, '等待 PDA 装柜完成异步收敛', completion.response,
|
||||
`attempts=${completion.attempts}`, OPTIONS)
|
||||
const directPdaCompletion = completed?.taskStatus === 'COMPLETED'
|
||||
&& completed?.workflowStatus === 'COMPLETED'
|
||||
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
|
||||
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completion.response,
|
||||
directPdaCompletion
|
||||
&& completed?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, OPTIONS)
|
||||
|
||||
const operationsResponse = await request(context, 'GET', `${PDA}/tasks/${taskId}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '回查完成后的 PDA 操作审计', operationsResponse, '', OPTIONS)
|
||||
|
||||
@@ -71,9 +71,13 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', (
|
||||
assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/)
|
||||
assert.match(truckAttachmentDialog, /上传并关联/)
|
||||
assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/)
|
||||
assert.match(truckAcceptance, /attachmentType=BILL_OF_LADING/)
|
||||
assert.match(truckAcceptance, /派车前保留有效 BILL_OF_LADING 提单附件/)
|
||||
assert.match(truckAcceptance, /缺提单号与文件明确拒绝/)
|
||||
for (const type of ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER'])
|
||||
assert.match(truckAttachmentDialog, new RegExp(type))
|
||||
assert.doesNotMatch(truckAttachmentDialog, /BILL_OF_LADING/)
|
||||
for (const label of ['上传并关联DISPATCH_ORDER拖车附件', '删除首次上传的拖车附件引用',
|
||||
'重新引用平台归属拖车附件', '附件列表包含重新引用的DISPATCH_ORDER', '删除重新引用的拖车附件'])
|
||||
assert.match(truckAcceptance, new RegExp(label))
|
||||
assert.doesNotMatch(truckAcceptance, /attachmentType=BILL_OF_LADING|派车前保留有效 BILL_OF_LADING 提单附件/)
|
||||
assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/)
|
||||
})
|
||||
|
||||
@@ -97,7 +101,11 @@ test('PDA task opens only after no-number push, release, four-field link and pla
|
||||
assert.match(containerLoadingAcceptance, /订舱放舱为 RELEASED 且不预生成真实货柜/)
|
||||
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
|
||||
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
|
||||
assert.match(containerLoadingAcceptance, /PDA}\/tasks\/\$\{taskId\}\/evidence/)
|
||||
assert.match(containerLoadingAcceptance, /evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content/)
|
||||
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
|
||||
assert.match(containerLoadingAcceptance, /waitForCompletedTask/)
|
||||
assert.match(containerLoadingAcceptance, /taskStatus === 'COMPLETED' && task\?\.workflowStatus === 'COMPLETED'/)
|
||||
assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/)
|
||||
assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/)
|
||||
@@ -109,10 +117,9 @@ test('PDA task opens only after no-number push, release, four-field link and pla
|
||||
})
|
||||
|
||||
test('customer public display uses tenantName without a legalName fallback', () => {
|
||||
for (const source of [truckList, containerList]) {
|
||||
assert.match(source, /label: customer\.tenantName/)
|
||||
}
|
||||
assert.match(truckForm, /return customer\.tenantName/)
|
||||
assert.match(containerList, /label: customer\.tenantName/)
|
||||
for (const source of [truckList, truckForm])
|
||||
assert.doesNotMatch(source, /customer\.(?:tenantName|legalName)/)
|
||||
for (const source of [truckList, containerList, truckForm])
|
||||
assert.doesNotMatch(source, /customer\.legalName\?\.trim\(\) \|\| customer\.tenantName/)
|
||||
assert.match(matrix, /客户公开名称一致性[\s\S]*已统一 `tenantName`[\s\S]*`LANDED`/)
|
||||
|
||||
@@ -107,19 +107,19 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', ()
|
||||
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
|
||||
})
|
||||
|
||||
test('PDA complete OpenAPI freezes optional truck coupling and atomic rollback', () => {
|
||||
test('PDA complete OpenAPI freezes reliable async acceptance and checkpoint recovery', () => {
|
||||
assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/)
|
||||
assert.match(pdaOpenApi, /拖车状态不是前置门禁/)
|
||||
assert.match(pdaOpenApi, /没有有效拖车则直接继续/)
|
||||
assert.match(pdaOpenApi, /恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史/)
|
||||
assert.match(pdaOpenApi, /多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚/)
|
||||
assert.match(pdaOpenApi, /同operationId重放返回冻结结果,不重复推进拖车或写历史/)
|
||||
assert.match(pdaOpenApi, /持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实/)
|
||||
assert.match(pdaOpenApi, /后台worker按稳定入库单顺序以checkpoint恢复/)
|
||||
assert.match(pdaOpenApi, /请求事务内不调用任何远程服务/)
|
||||
assert.match(pdaOpenApi, /同operationId重放立即返回相同PROCESSING或最终结果/)
|
||||
assert.match(pdaOpenApi, /不同operationId在处理期间返回A0409/)
|
||||
})
|
||||
|
||||
test('PDA final completion refreshes post-settlement versions for full and partial split', () => {
|
||||
ordered(loadingPickService, [
|
||||
'settlementService.settle(taskId, operatorId, operatorName)',
|
||||
'loadingDeliveryService.completeAfterPdaSettlement',
|
||||
'settlementService.settleOwner(',
|
||||
'loadingDeliveryService.completeAfterPdaSettlement(',
|
||||
])
|
||||
assert.doesNotMatch(loadingPickService,
|
||||
/new LoadingRequests\.CompleteLoadingRequest\(entry\.getValue\(\)\.version\(\), remark\)/)
|
||||
@@ -133,7 +133,8 @@ test('PDA final completion refreshes post-settlement versions for full and parti
|
||||
'pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement',
|
||||
'pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange',
|
||||
]) assert.match(loadingDeliveryServiceTest, new RegExp(regression))
|
||||
assert.match(loadingDeliveryServiceTest, /verify\(repository, never\(\)\)\.completeLoadedChild\(10, 20, 7, 6\)/)
|
||||
assert.match(loadingDeliveryServiceTest, /verify\(repository\)\.findInboundForUpdate\(10L, 21L\)/)
|
||||
assert.match(loadingDeliveryServiceTest, /verify\(repository\)\.completeLoadedChild\(10L, 20L, 9L, 9\)/)
|
||||
assert.match(loadingDeliveryServiceTest, /verify\(histories, never\(\)\)\.insert/)
|
||||
})
|
||||
|
||||
@@ -159,7 +160,8 @@ test('HTTP finalizer races distinct completion keys and persists exactly one ter
|
||||
'读取结算前 PDA 操作历史基线',
|
||||
'const completeAttempts = [1, 2].map',
|
||||
'const concurrentCompleteResponses = await Promise.all',
|
||||
'并发完成后回查 PDA 操作审计',
|
||||
'异步完成命令收敛到终态',
|
||||
'const operationsAfterConcurrent = completion.operations',
|
||||
'两个不同幂等键并发 complete 仅一成功且只写一条完成审计',
|
||||
'订舱与五类证据等保留门禁齐备后完成装柜',
|
||||
'LOADED 完成请求 Idempotency-Key 重放保持终态',
|
||||
@@ -176,3 +178,17 @@ test('HTTP finalizer races distinct completion keys and persists exactly one ter
|
||||
assert.match(finalize, /concurrentCompleteOperations\.length === 1/)
|
||||
assert.match(finalize, /concurrentCompleteOperations\[0\]\?\.operationId === completeBody\.operationId/)
|
||||
})
|
||||
|
||||
test('worker final Firstleg convergence is one short transaction after remote owner settlement', () => {
|
||||
ordered(loadingPickService, [
|
||||
'settlementService.settleOwner(',
|
||||
'loadingDeliveryService.completeAfterPdaSettlement(',
|
||||
'completionTransaction.executeWithoutResult',
|
||||
'completeTruckAfterCommit(',
|
||||
])
|
||||
const finalizer = loadingPickService.slice(loadingPickService.indexOf('private void finalizeCompletion'),
|
||||
loadingPickService.indexOf('private void requireColoadConservation'))
|
||||
for (const token of ['COMPLETE_LOADING', 'finishFromPda', 'syncInboundProjections',
|
||||
'publishCompletedEvent', 'completeAccepted', 'completionCommands.complete']) assert.match(finalizer, new RegExp(token))
|
||||
assert.doesNotMatch(finalizer, /settlementService|completeTruckAfterCommit/)
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat
|
||||
|
||||
test('pending backend contract derives workflow from task items instead of assignment alone', () => {
|
||||
assert.match(openApi,
|
||||
/PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW\][^\r\n]*}/)
|
||||
/PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED\][^\r\n]*}/)
|
||||
assert.match(mapper,
|
||||
/WHEN SUM\(CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/)
|
||||
assert.doesNotMatch(mapper,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
const readFirstlegTest = (name) => readFileSync(resolve(here,
|
||||
'../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8')
|
||||
|
||||
test('current-marker departure batch proves four documents photos customs and container-level confirm', () => {
|
||||
const customs = read('phase3-customs.mjs')
|
||||
@@ -48,3 +50,25 @@ test('outbound-created query supports automated or manual carrier then reaches P
|
||||
assert.match(pda, /deploymentBlocker: true/)
|
||||
assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/)
|
||||
})
|
||||
|
||||
test('demo package locks provider failure retry and refuses stray facts on failed calls', () => {
|
||||
const scheduler = readFirstlegTest('CarrierScheduleQuerySchedulerTest.java')
|
||||
const service = readFirstlegTest('CarrierScheduleQueryServiceTest.java')
|
||||
assert.match(scheduler, /retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts/)
|
||||
assert.match(scheduler, /verify\(service, never\(\)\)\.applyProviderObservation/)
|
||||
assert.match(scheduler, /completeScheduledAttempt\(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"\)/)
|
||||
assert.match(service, /retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot/)
|
||||
assert.match(service, /containsExactly\(1, 2, 3\)/)
|
||||
assert.match(service, /retryThatWouldReachTheNextSlotWaitsForTheSlotInstead/)
|
||||
})
|
||||
|
||||
test('demo package exposes the stable Admin task, node, exception and history DTO contract', () => {
|
||||
const nodes = read('phase4-carrier-nodes.mjs')
|
||||
assert.match(nodes, /trackingType === 'CONTAINER_NO'/)
|
||||
assert.match(nodes, /validContainerNo\(task\?\.trackingNo\)/)
|
||||
assert.match(nodes, /Array\.isArray\(detail\?\.nodes\)/)
|
||||
assert.match(nodes, /nodes\.every\(validNode\)/)
|
||||
assert.match(nodes, /exceptions\.every\(validException\)/)
|
||||
assert.match(nodes, /histories\.every\(validHistory\)/)
|
||||
assert.match(nodes, /!Object\.hasOwn\(detail \|\| \{\}, 'attempts'\)/)
|
||||
})
|
||||
|
||||
@@ -10,10 +10,11 @@ import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.m
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => {
|
||||
test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE routes', () => {
|
||||
assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'PRIVATE')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'invalid' } }), 'FBA')
|
||||
const orders = read('orders.mjs')
|
||||
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
|
||||
assert.match(orders, /destinationType: master\.destinationType/)
|
||||
@@ -88,7 +89,8 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/)
|
||||
const pda = read('phase4-overseas-pda.mjs')
|
||||
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/)
|
||||
assert.match(pda, /skuFixtures\(context, producer\)/)
|
||||
assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks\)/)
|
||||
assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/)
|
||||
})
|
||||
|
||||
test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => {
|
||||
|
||||
@@ -377,7 +377,10 @@ test('container loading enforces channel isolation and stable PDA identity acros
|
||||
assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/)
|
||||
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
|
||||
assert.match(value, /if \(!frozenTransportMatches\) return/)
|
||||
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)
|
||||
assert.match(value, /const releaseVersion = Number\(bookingDocument\?\.bookingVersion\)/)
|
||||
assert.match(value, /expectedVersion: releaseVersion, reason: context\.marker/)
|
||||
assertOrder(value, ['上传有效订舱确认文件', '订舱确认文件已关联且版本递增',
|
||||
'订舱放舱为 RELEASED 且不预生成真实货柜'])
|
||||
assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/)
|
||||
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
|
||||
assert.doesNotMatch(batch, /containerNo:/)
|
||||
@@ -412,16 +415,18 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au
|
||||
const convergence = source('./phase3-pda-convergence.mjs')
|
||||
assertOrder(container, [
|
||||
'最后一箱拣货后 PDA 任务等待人工确认装柜完成',
|
||||
'上传柜次 ${evidenceType} 真实证据文件',
|
||||
'绑定柜次 ${evidenceType} 装柜证据',
|
||||
'上传并绑定柜次 ${evidenceType} 真实证据文件',
|
||||
'${evidenceType} 有效证据归属稳定且锁码门禁完整',
|
||||
'PDA 完成前回查五类有效装柜证据与锁码',
|
||||
'PDA 人工确认装柜完成',
|
||||
'PDA 提交人工确认装柜完成',
|
||||
'等待 PDA 装柜完成异步收敛',
|
||||
])
|
||||
assert.match(container, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/)
|
||||
assert.match(container, /businessType === `LOADING_\$\{evidenceType\}`/)
|
||||
assert.match(container, /form\.append\('evidenceType', evidenceType\)/)
|
||||
assert.match(container, /new Blob\(\[png\], \{ type: 'image\/png' \}\)/)
|
||||
assert.match(container, /item\.evidenceType === 'SEAL' && item\.sealNo === sealNo/)
|
||||
assert.match(container, /if \(!evidenceTypes\.every[\s\S]*\) return/)
|
||||
assert.match(container, /requestBinary\('GET',[\s\S]*files\/loading-evidence/)
|
||||
assert.match(container, /requestBinary\('GET',[\s\S]*\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content/)
|
||||
assert.match(container, /回查完成后的 PDA 操作审计/)
|
||||
assert.match(container, /item\.actionType === 'BATCH'/)
|
||||
assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/)
|
||||
@@ -502,7 +507,7 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea
|
||||
assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/)
|
||||
assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/)
|
||||
assert.match(entry, /this\.context\.phase3LoadingIncrementalDetailCount = 4/)
|
||||
assert.match(measurement, /增量 fixture 形成四条真实独立货物测量明细/)
|
||||
assert.match(measurement, /增量 fixture 形成动态真实独立货物测量明细/)
|
||||
assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/)
|
||||
assert.match(measurement, /items: draftItems/)
|
||||
assert.match(measurement, /expectedPackages === originalPackageCount/)
|
||||
@@ -511,20 +516,22 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea
|
||||
assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/)
|
||||
})
|
||||
|
||||
test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {
|
||||
test('phase3 loading fixture paginates A0 candidates and refuses mass location mutations', () => {
|
||||
const entry = source('../admin-test-env-acceptance.mjs')
|
||||
const measurement = source('./inbound-measurement.mjs')
|
||||
const orders = source('./orders.mjs')
|
||||
assertOrder(entry, [
|
||||
'disableAvailableLocationsForA0(warehouseId)',
|
||||
'this.context.phase3LoadingRequireSharedOverflow = true',
|
||||
'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)',
|
||||
'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true',
|
||||
"executeModule('inbound-measurement-phase3-loading-fixture'",
|
||||
'restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)',
|
||||
'restoreAvailableLocationsAfterA0(warehouseId, isolation)',
|
||||
])
|
||||
assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/)
|
||||
assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/)
|
||||
assert.match(entry, /A0 大仓保护:不批量停用普通库位/)
|
||||
assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/)
|
||||
assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/)
|
||||
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
|
||||
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
|
||||
assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/)
|
||||
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
|
||||
})
|
||||
|
||||
@@ -558,9 +565,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
|
||||
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
|
||||
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
|
||||
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
|
||||
assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/)
|
||||
assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/)
|
||||
assert.match(serviceCenter, /customerId, customerName: null, carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
|
||||
assert.match(serviceCenter, /oceanBookingId: ocean\.id/)
|
||||
assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/)
|
||||
assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
|
||||
assert.doesNotMatch(serviceCenter, /customerId:|customerName:/)
|
||||
assert.doesNotMatch(serviceCenter, /链路承运商-/)
|
||||
assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/)
|
||||
const schedules = source('./phase3-port-schedules.mjs')
|
||||
@@ -594,7 +603,8 @@ test('container overview always follows phase3 loading in default and custom mod
|
||||
|
||||
const entry = source('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||
assert.match(entry, /modules: normalizeModuleOrder\(/)
|
||||
assert.match(entry, /const modules = normalizeModuleOrder\(/)
|
||||
assert.match(entry, /return \{[\s\S]{0,500}modules,/)
|
||||
})
|
||||
|
||||
test('overview does not fall back to a container fixture when loading evidence was requested', () => {
|
||||
|
||||
@@ -125,7 +125,7 @@ function markerOwnedContext() {
|
||||
const task = {
|
||||
id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID,
|
||||
containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
||||
trackingNo: 'TSTU1234567', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM',
|
||||
trackingNo: 'ZCSU7881970', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM',
|
||||
latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-24T08:00:00', version: 0,
|
||||
lastSuccessAt: null, sourceUpdatedAt: '2026-08-24T08:00:00', expiresAt: null,
|
||||
stoppedAt: null, stopReason: null,
|
||||
@@ -165,7 +165,7 @@ function markerOwnedContext() {
|
||||
accessMode: 'OFFICIAL_API', automated: true, coverageGap: false,
|
||||
}])
|
||||
if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) return ok({ records: [{
|
||||
query: { ...task, id: '9007199254740993999', trackingNo: 'HISTORICAL000' }, expired: false,
|
||||
query: { ...task, id: '9007199254740993999', trackingNo: 'MATU4646247' }, expired: false,
|
||||
}], page: 1, size: 20, total: 2, pages: 1 })
|
||||
if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}`) return ok(detail())
|
||||
if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}/history`) return ok(clone(histories))
|
||||
|
||||
@@ -10,14 +10,25 @@ export const STANDARD_NODES = Object.freeze([
|
||||
])
|
||||
export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10])))
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function number(value) { return Number.isFinite(Number(value)) }
|
||||
function nullableText(value) { return value == null || typeof value === 'string' }
|
||||
function nullableId(value) { return value == null || validLongId(value) }
|
||||
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
|
||||
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
|
||||
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
|
||||
function validContainerNo(value) {
|
||||
if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index)
|
||||
}
|
||||
return Number(value[10]) === (sum % 11) % 10
|
||||
}
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||
function monotonicRejected(response) {
|
||||
@@ -45,7 +56,8 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e
|
||||
function validTask(task) {
|
||||
return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId)
|
||||
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
|
||||
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
|
||||
&& validLongId(task?.containerBookingId) && task?.trackingType === 'CONTAINER_NO'
|
||||
&& validContainerNo(task?.trackingNo) && nullableText(task?.latestActualNode)
|
||||
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
|
||||
&& number(task?.version)
|
||||
&& ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
|
||||
|
||||
@@ -7,7 +7,7 @@ const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.
|
||||
const controllerSource = [
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java',
|
||||
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n')
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
|
||||
@@ -291,10 +291,14 @@ async function convertReceivingContainers(context, producers) {
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
||||
? convertedResponse.body.data : []
|
||||
const convertedInboundCodes = new Set(plans
|
||||
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
|
||||
.map((line) => id(line?.inboundCode)).filter(nonBlank))
|
||||
context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse,
|
||||
plans.length === Number(summary.inboundCodeCount)
|
||||
&& plans.every((item) => nonBlank(item?.planNo) && Array.isArray(item?.lines) && item.lines.length > 0),
|
||||
`plans=${plans.length}, expected=${summary.inboundCodeCount}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0)
|
||||
&& convertedInboundCodes.size === Number(summary.inboundCodeCount),
|
||||
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${summary.inboundCodeCount}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== Number(summary.inboundCodeCount)) return false
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function run(context) {
|
||||
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
|
||||
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`,
|
||||
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2,
|
||||
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
|
||||
currency: 'RMB', remark: context.marker, expectedVersion: 0,
|
||||
}
|
||||
if (linkContainer) {
|
||||
|
||||
@@ -39,9 +39,3 @@ export function validEventReplay(before, after) {
|
||||
&& Number(before?.detailCount) === Number(after?.detailCount)
|
||||
&& Number(before?.historyCount) === Number(after?.historyCount)
|
||||
}
|
||||
|
||||
export function adminPreservesOrderNumber(apiValue, displayedValue, copiedValue) {
|
||||
return TAILLEG_ORDER_NUMBER.pattern.test(String(apiValue ?? ''))
|
||||
&& displayedValue === apiValue
|
||||
&& copiedValue === apiValue
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
adminPreservesOrderNumber,
|
||||
IMMUTABLE_TAILLEG_EVENT_FIXTURE,
|
||||
TAILLEG_ORDER_NUMBER,
|
||||
validContainerOrderGroup,
|
||||
@@ -13,18 +12,14 @@ import {
|
||||
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java')
|
||||
const producer = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java')
|
||||
const producerRows = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java')
|
||||
const eventV2Tests = read('../../common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java')
|
||||
const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java')
|
||||
const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java')
|
||||
const orderSummary = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java')
|
||||
const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java')
|
||||
const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java')
|
||||
const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java')
|
||||
const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java')
|
||||
const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
|
||||
const adminPresentation = read('../../../apps/admin-web/src/modules/overseas-warehouse/lastMilePresentation.ts')
|
||||
const adminList = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue')
|
||||
const adminDetail = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')
|
||||
|
||||
test('new 15-order event requires one non-empty trusted owner snapshot', () => {
|
||||
const orders = Array.from({ length: 15 }, (_, index) => ({
|
||||
@@ -44,26 +39,27 @@ test('the processed historical event is immutable and cannot be reused for accep
|
||||
})
|
||||
})
|
||||
|
||||
test('V2 producer freezes customerId and customerName and rejects a blank name', () => {
|
||||
test('V2 event contract freezes customerId and customerName and rejects a blank or mixed owner', () => {
|
||||
assert.match(eventV2, /List<Item> items,\s*String customerId,\s*String customerName,\s*String orderType/)
|
||||
assert.match(producerRows, /b\.customer_name/)
|
||||
assert.match(producer, /first\.getCustomerName\(\)/)
|
||||
assert.match(producer, /(?:customerName|getCustomerName)[\s\S]{0,300}(?:isBlank|hasText)/)
|
||||
assert.match(eventV2, /customerId\s*=\s*customerId\.trim\(\)/)
|
||||
assert.match(eventV2, /customerName\s*=\s*customerName\.trim\(\)/)
|
||||
assert.match(eventV2, /orders must share one immutable customerId\/customerName snapshot/)
|
||||
assert.match(eventV2Tests, /rejectsMissingImmutableCustomerName/)
|
||||
assert.match(eventV2Tests, /freezesTrimmedOwnerSnapshotAndRejectsMixedOwners/)
|
||||
})
|
||||
|
||||
test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => {
|
||||
assert.match(consumer, /source\.customerName\(\)/)
|
||||
assert.match(consumer, /customerName[\s\S]{0,240}(?:isBlank|hasText)/)
|
||||
assert.match(consumer, /OwnerSnapshot owner\s*=\s*requireOwnerSnapshot\(payload\.orders\(\),\s*envelope\.tenantId\(\)\)/)
|
||||
assert.match(consumer, /requiredOwnerValue\(orders\.get\(0\)\.customerName\(\),\s*"customerName"\)/)
|
||||
assert.match(consumer, /value\s*==\s*null\s*\|\|\s*value\.isBlank\(\)/)
|
||||
assert.match(consumer, /order\.setCustomerName\(customerName\)/)
|
||||
assert.match(orderEntity, /private String customerName;/)
|
||||
assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/)
|
||||
})
|
||||
|
||||
test('Lastleg query DTO and Admin list/detail use the frozen owner name', () => {
|
||||
test('Lastleg query DTO uses the persisted frozen owner name', () => {
|
||||
assert.match(orderSummary, /String customerName/)
|
||||
assert.match(orderService, /o\.getCustomerName\(\)/)
|
||||
assert.match(adminApi, /customerName\?: string \| null/)
|
||||
assert.match(adminPresentation, /owner:\s*text\(dto\.customerName\)/)
|
||||
assert.match(adminDetail, /label:\s*'货主',\s*value:\s*text\(order\?\.customerName\)/)
|
||||
})
|
||||
|
||||
test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => {
|
||||
@@ -76,7 +72,8 @@ test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and
|
||||
assert.match(generator, /%04d|padStart\(4|String\.format/)
|
||||
assert.match(generatorTests, /(?:concurrent|并发)/i)
|
||||
assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i)
|
||||
assert.match(generatorTests, /\^LMO-\\d\{6\}-\\d\{4\}\$/)
|
||||
assert.match(generatorTests, /LMO-260827-0001/)
|
||||
assert.match(generatorTests, /LMO-260828-0001/)
|
||||
})
|
||||
|
||||
test('one container with 15 inbound rows shares one new LMO number', () => {
|
||||
@@ -86,8 +83,9 @@ test('one container with 15 inbound rows shares one new LMO number', () => {
|
||||
assert.equal(validContainerOrderGroup(rows), true)
|
||||
assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
|
||||
? { ...row, orderNo: 'LMO-260827-0043' } : row)), false)
|
||||
assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.[^(]+\(payload\.containerNo\(\)\);[\s\S]*for \(int index/)
|
||||
assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource\)/)
|
||||
assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.next\(\);[\s\S]*for \(int index/)
|
||||
assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/)
|
||||
assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneNewLmoNumber/)
|
||||
})
|
||||
|
||||
test('same event replay adds nothing and preserves the allocated number', () => {
|
||||
@@ -95,16 +93,6 @@ test('same event replay adds nothing and preserves the allocated number', () =>
|
||||
assert.equal(validEventReplay(before, { ...before }), true)
|
||||
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
|
||||
assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/)
|
||||
assert.match(consumerTests, /(?:replay|duplicate|重复)[\s\S]{0,900}(?:orderNo|OrderNo)/i)
|
||||
})
|
||||
|
||||
test('Admin displays and copies the exact queried number without reformatting', () => {
|
||||
const value = 'LMO-260827-0042'
|
||||
assert.equal(adminPreservesOrderNumber(value, value, value), true)
|
||||
assert.equal(adminPreservesOrderNumber(value, value.replaceAll('-', ''), value), false)
|
||||
assert.match(adminPresentation, /orderNo:\s*text\(dto\.orderNo\)/)
|
||||
assert.match(adminList, /prop:\s*'orderNo'/)
|
||||
assert.match(adminDetail, /reference-value="referenceValue"/)
|
||||
const adminOrderSources = `${adminList}\n${adminDetail}`
|
||||
assert.match(adminOrderSources, /(?:clipboard\.writeText|copyText|copyToClipboard)[\s\S]{0,240}(?:orderNo|referenceValue)|(?:orderNo|referenceValue)[\s\S]{0,240}(?:clipboard\.writeText|copyText|copyToClipboard)/)
|
||||
assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/)
|
||||
assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,44 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs'
|
||||
import { activeCustomerIdentity, inspectOceanBookingIdentity, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs'
|
||||
|
||||
test('booking prerequisite resolves an ACTIVE platform customer without fixture state', () => {
|
||||
assert.deepEqual(activeCustomerIdentity({ id: '21', tenantName: '现有客户' }),
|
||||
{ customerId: '21', customerName: '现有客户' })
|
||||
assert.deepEqual(activeCustomerIdentity({ tenantId: 22, customerName: '备用客户' }),
|
||||
{ customerId: '22', customerName: '备用客户' })
|
||||
assert.equal(activeCustomerIdentity({ id: '23' }), null)
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /\/api\/platform\/v1\/iam\/tenants\?page=1&size=100&status=ACTIVE/)
|
||||
assert.match(source, /fixtureCustomerId[\s\S]*\|\| candidates\[0\]/)
|
||||
})
|
||||
|
||||
test('channel catalog accepts the formal array response shape', () => {
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /const channelRows = records\(data\(channelsResponse\)\); const channel = channelRows\[0\]/)
|
||||
assert.doesNotMatch(source, /\(data\(channelsResponse\) \|\| \[\]\)\[0\]/)
|
||||
})
|
||||
|
||||
test('ACTIVE domestic platform warehouses are scanned through bounded pagination', () => {
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /for \(let page = 1; page <= 100; page\+\+\)/)
|
||||
assert.match(source, /warehouse-admin\?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=\$\{page\}&size=100/)
|
||||
assert.match(source, /const total = Number\(warehousePage\?\.total \?\? warehouseRows\.length\)/)
|
||||
assert.match(source, /page \* 100 >= total/)
|
||||
assert.match(source, /完整分页平台仓并解析有效海运渠道/)
|
||||
assert.doesNotMatch(source, /193000000000200001|193000000000200002/)
|
||||
})
|
||||
|
||||
test('platform warehouse scope is fixed to zero without requiring response tenantId', () => {
|
||||
assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41', warehouseName: '宁波仓' }),
|
||||
{ warehouseId: '41', platformTenantId: '0' })
|
||||
assert.deepEqual(platformWarehouseIdentity({ id: 42, warehouseName: '义乌仓' }),
|
||||
{ warehouseId: '42', platformTenantId: '0' })
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /platformWarehouseIdentity\(item\)[\s\S]*platformTenantId: '0'/)
|
||||
assert.doesNotMatch(source, /platformWarehouseIdentity\(item\)[^{]*\{[^}]*tenantId/)
|
||||
})
|
||||
|
||||
test('booking identity is marker scoped and contains no customer snapshot', () => {
|
||||
const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', warehouseId: '41' })
|
||||
@@ -21,6 +58,7 @@ test('payload requires booking container and carrier but never emits customer fi
|
||||
{ oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' })
|
||||
assert.equal(Object.hasOwn(payload, 'customerId'), false)
|
||||
assert.equal(Object.hasOwn(payload, 'customerName'), false)
|
||||
assert.equal(Object.hasOwn(payload, 'receivableAmount'), false)
|
||||
})
|
||||
|
||||
test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => {
|
||||
@@ -49,11 +87,26 @@ test('backend freezes released booking real container bill file and carrier rule
|
||||
}
|
||||
})
|
||||
|
||||
test('HTTP regression covers no-customer response and submit idempotency matrix', () => {
|
||||
test('HTTP regression is self-contained and covers no-customer and idempotency matrix', () => {
|
||||
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
|
||||
for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移',
|
||||
'同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label))
|
||||
assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/)
|
||||
for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱',
|
||||
'绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移',
|
||||
'同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建',
|
||||
'错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建',
|
||||
'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段',
|
||||
'上传并关联DISPATCH_ORDER拖车附件', '拖车附件类型仅使用运营附件而非订舱提单',
|
||||
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件', '附件列表包含重新引用的DISPATCH_ORDER',
|
||||
'删除重新引用的拖车附件',
|
||||
'派车命令返回状态身份与版本正确', '派车后回查拖车完整详情',
|
||||
'详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label))
|
||||
assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/)
|
||||
assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/)
|
||||
assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/)
|
||||
assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/)
|
||||
assert.doesNotMatch(source, /receivableAmount\s*:/)
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/)
|
||||
assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/)
|
||||
})
|
||||
|
||||
test('schema owns platform attachments and one active appointment per real container', () => {
|
||||
|
||||
@@ -1,113 +1,249 @@
|
||||
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
|
||||
|
||||
const MODULE = 'truck-appointments'
|
||||
const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/truck-appointments'
|
||||
const TRUCKS = '/api/platform/v1/truck-appointments'
|
||||
const BOOKINGS = '/api/platform/v1/ocean-bookings'
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function failure(response) { return response?.http === 200 && codeOf(response) !== '200' }
|
||||
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
|
||||
function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') }
|
||||
function records(value) { return Array.isArray(value) ? value : value?.records || [] }
|
||||
|
||||
export function platformWarehouseIdentity(item) {
|
||||
return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' }
|
||||
}
|
||||
|
||||
export function activeCustomerIdentity(item) {
|
||||
const customerId = id(item?.id || item?.tenantId)
|
||||
const customerName = String(item?.tenantName || item?.customerName || item?.name || '').trim()
|
||||
return customerId && customerName ? { customerId, customerName } : null
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
const raw = await response.text(); let parsed = null
|
||||
try { parsed = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null,
|
||||
requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||
}
|
||||
|
||||
export function inspectOceanBookingIdentity(marker, resource, detail = null) {
|
||||
if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' }
|
||||
if (resource.marker !== marker) return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` }
|
||||
const booking = detail?.oceanBooking || null
|
||||
if (booking && id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' }
|
||||
const identity = { oceanBookingId: id(resource.id), warehouseId: id(resource.warehouseId || booking?.warehouseId) }
|
||||
const missingFields = Object.entries(identity).filter(([, value]) => !value).map(([key]) => key)
|
||||
return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields }
|
||||
: { identity, reason: null, missingFields: [] }
|
||||
if (!resource?.id || resource.marker !== marker) return { identity: null, reason: '订舱不属于本轮' }
|
||||
const booking = detail?.oceanBooking || resource
|
||||
if (id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' }
|
||||
return { identity: { oceanBookingId: id(resource.id), warehouseId: id(booking.warehouseId) }, reason: null, missingFields: [] }
|
||||
}
|
||||
|
||||
export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) {
|
||||
const supplier = context.createdResources?.truckSupplier || {}
|
||||
return {
|
||||
outboundOrderId: null, oceanBookingId: identity.oceanBookingId,
|
||||
containerBookingId: identity.containerBookingId, loadingPlanId: identity.loadingPlanId || null,
|
||||
warehouseId: identity.warehouseId || null, carrierId: id(supplier.supplierId) || null,
|
||||
carrierName: supplier.supplierName || null, billOfLadingNo: identity.billOfLadingNo || null,
|
||||
fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005',
|
||||
vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人',
|
||||
storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
|
||||
const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {}
|
||||
return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId,
|
||||
containerBookingId: identity.containerBookingId, loadingPlanId: null, warehouseId: identity.warehouseId,
|
||||
carrierId: id(supplier.supplierId), carrierName: supplier.supplierName,
|
||||
billOfLadingNo: identity.billOfLadingNo, fleetContact: '车队联系人', fleetPhone: '13800000004',
|
||||
driverName: '司机验收', driverPhone: '13800000005', vehicleNo: `粤B-${suffix.slice(-6)}`,
|
||||
storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006',
|
||||
storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
|
||||
packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`,
|
||||
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0,
|
||||
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides,
|
||||
}
|
||||
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0,
|
||||
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides }
|
||||
}
|
||||
|
||||
function currentIdentity(context) {
|
||||
const fixture = [context.createdResources?.phase3ContainerLoading, context.createdResources?.phase3Loading]
|
||||
.find((item) => item?.marker === context.marker && id(item.oceanBookingId) && id(item.containerBookingId))
|
||||
if (!fixture) return null
|
||||
return { oceanBookingId: id(fixture.oceanBookingId), containerBookingId: id(fixture.containerBookingId),
|
||||
loadingPlanId: id(fixture.loadingPlanId) || null, warehouseId: id(fixture.warehouseId) || null,
|
||||
billOfLadingNo: fixture.billOfLadingNo || null }
|
||||
async function activeSupplier(context, category, suffix) {
|
||||
const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE&page=1&size=100`)
|
||||
const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE')
|
||||
if (existing) return { supplierId: id(existing.id), supplierName: existing.name }
|
||||
const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [],
|
||||
region: '深圳', contactName: '验收联系人', phone: '13800000000', status: 'ACTIVE', remark: context.marker,
|
||||
})
|
||||
const value = data(created); const supplierId = id(value?.id || value)
|
||||
context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS)
|
||||
return supplierId ? { supplierId, supplierName: value?.name || `${category}-验收-${suffix}` } : null
|
||||
}
|
||||
|
||||
async function bookingMaster(context, suffix) {
|
||||
const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|
||||
|| context.context?.customerId || context.customerId)
|
||||
const customersResponse = await request(context, 'GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
|
||||
const candidates = records(data(customersResponse))
|
||||
const customer = activeCustomerIdentity(candidates.find((item) =>
|
||||
fixtureCustomerId && id(item?.id || item?.tenantId) === fixtureCustomerId) || candidates[0])
|
||||
if (!customer) {
|
||||
context.addStep(MODULE, '通过正式平台客户查询解析 ACTIVE 订舱客户', customersResponse, false,
|
||||
`preferredCustomerId=${fixtureCustomerId || 'missing'}`, OPTIONS)
|
||||
return null
|
||||
}
|
||||
let lastWarehousesResponse = null
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const warehousesResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/warehouse-admin?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=${page}&size=100`)
|
||||
lastWarehousesResponse = warehousesResponse
|
||||
const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage)
|
||||
for (const item of warehouseRows) {
|
||||
const { warehouseId } = platformWarehouseIdentity(item)
|
||||
if (!warehouseId) continue
|
||||
const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`)
|
||||
const channelRows = records(data(channelsResponse)); const channel = channelRows[0]
|
||||
if (channel?.channelCode) {
|
||||
context.addStep(MODULE, '完整分页平台仓并解析有效海运渠道', channelsResponse, true,
|
||||
`warehousePage=${page}, warehouseRecords=${warehouseRows.length}, channelRecords=${channelRows.length}, warehouseId=${warehouseId}`, OPTIONS)
|
||||
return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName }
|
||||
}
|
||||
}
|
||||
const total = Number(warehousePage?.total ?? warehouseRows.length)
|
||||
if (warehouseRows.length === 0 || page * 100 >= total) break
|
||||
}
|
||||
context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', lastWarehousesResponse, false,
|
||||
`platformTenantId=0, customerId=${customer.customerId}`, OPTIONS)
|
||||
return null
|
||||
}
|
||||
|
||||
function isoContainerNo(marker) {
|
||||
const values = {}; let value = 10
|
||||
for (let code = 65; code <= 90; code++) { while (value % 11 === 0) value++; values[String.fromCharCode(code)] = value++ }
|
||||
const serial = String([...marker].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % 1_000_000).padStart(6, '0')
|
||||
const base = `TSTU${serial}`; let sum = 0
|
||||
;[...base].forEach((char, index) => { sum += (values[char] ?? Number(char)) * (2 ** index) })
|
||||
return `${base}${(sum % 11) % 10}`
|
||||
}
|
||||
|
||||
async function fixture(context) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) || 'TRUCK'
|
||||
const master = await bookingMaster(context, suffix); if (!master?.customerId) return null
|
||||
const bookingAgent = await activeSupplier(context, 'BOOKING', suffix)
|
||||
const truckSupplier = await activeSupplier(context, 'TRUCKING', suffix)
|
||||
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
||||
if (!bookingAgent || !truckSupplier || !carrier?.id) return null
|
||||
const draft = oceanBookingDraft(context, suffix, master, {
|
||||
bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName,
|
||||
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
})
|
||||
const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft },
|
||||
{ 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })
|
||||
const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS)
|
||||
const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null
|
||||
const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version))
|
||||
form.append('documentType', 'BILL_OF_LADING')
|
||||
form.append('file', new Blob([`${context.marker}-BILL_OF_LADING`], { type: 'application/pdf' }), `bill-${suffix}.pdf`)
|
||||
const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form,
|
||||
context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) })
|
||||
const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS)
|
||||
const releaseResponse = await request(context, 'POST', `${BOOKINGS}/${bookingId}/release`,
|
||||
{ expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) })
|
||||
const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS)
|
||||
const requirement = released?.containerRequirements?.[0]; if (released?.oceanBooking?.status !== 'RELEASED' || !requirement?.id) return null
|
||||
const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode: master.channelCode,
|
||||
warehouseId: master.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
const simulation = context.apiSuccess(MODULE, '创建自包含模拟柜', simulationResponse, '', OPTIONS)
|
||||
const containerNo = isoContainerNo(context.marker); if (!simulation?.id) return null
|
||||
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${simulation.id}/ocean-booking-link`, {
|
||||
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version),
|
||||
})
|
||||
const linked = context.apiSuccess(MODULE, '绑定本轮真实 ISO 柜号', linkResponse, '', OPTIONS)
|
||||
if (linked?.containerNo !== containerNo) return null
|
||||
return { suffix, oceanBookingId: bookingId, containerBookingId: id(linked.id), warehouseId: master.warehouseId,
|
||||
billOfLadingNo: released.oceanBooking.billOfLadingNo, truckSupplier, bookingVersion: released.oceanBooking.version }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const identity = currentIdentity(context)
|
||||
const supplier = context.createdResources?.truckSupplier
|
||||
context.assert(MODULE, '本轮拖车前置仅依赖已放舱订舱、真实柜和拖车供应商', null,
|
||||
Boolean(identity?.oceanBookingId && identity?.containerBookingId && supplier?.supplierId),
|
||||
`oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}, carrierId=${id(supplier?.supplierId) || 'missing'}`, OPTIONS)
|
||||
if (!identity?.oceanBookingId || !identity?.containerBookingId || !supplier?.supplierId) return
|
||||
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10)
|
||||
const base = truckAppointmentPayload(context, suffix, identity)
|
||||
const identity = await fixture(context)
|
||||
context.assert(MODULE, '拖车模块自包含前置链完整', null, Boolean(identity?.oceanBookingId && identity?.containerBookingId),
|
||||
`oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}`, OPTIONS)
|
||||
if (!identity) return
|
||||
context.createdResources.truckSupplier = identity.truckSupplier
|
||||
const base = truckAppointmentPayload(context, identity.suffix, identity)
|
||||
context.assert(MODULE, '拖车请求不含客户字段', null,
|
||||
!Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), Object.keys(base).join(','), OPTIONS)
|
||||
for (const [label, payload] of [
|
||||
['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }],
|
||||
['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }],
|
||||
['缺少拖车供应商拒绝创建', { ...base, carrierId: null }],
|
||||
['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }],
|
||||
['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }],
|
||||
['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }],
|
||||
['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }],
|
||||
['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }],
|
||||
['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }],
|
||||
]) {
|
||||
const response = await request(context, 'POST', BASE, payload)
|
||||
context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS)
|
||||
const response = await request(context, 'POST', TRUCKS, payload)
|
||||
context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const createdResponse = await request(context, 'POST', BASE, base)
|
||||
const appointment = context.apiSuccess(MODULE, '创建订舱真实柜拖车预约', createdResponse, '', OPTIONS)
|
||||
const appointmentId = id(appointment?.id)
|
||||
context.assert(MODULE, '响应不含客户字段且冻结订舱柜号关系', createdResponse,
|
||||
Boolean(appointmentId) && appointment?.status === 'DRAFT' && !Object.hasOwn(appointment, 'customerId')
|
||||
&& !Object.hasOwn(appointment, 'customerName') && id(appointment?.oceanBookingId) === identity.oceanBookingId
|
||||
&& id(appointment?.containerBookingId) === identity.containerBookingId,
|
||||
`appointmentId=${appointmentId || 'missing'}`, OPTIONS)
|
||||
if (!appointmentId) return
|
||||
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED' }
|
||||
const path = `${BASE}/${appointmentId}`
|
||||
let version = Number(appointment.version)
|
||||
const submitKey = `${context.marker}-submit`
|
||||
const submitBody = { expectedVersion: version, reason: '并发幂等提交' }
|
||||
const createdResponse = await request(context, 'POST', TRUCKS, base)
|
||||
const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS)
|
||||
const appointmentId = id(appointment?.id); if (!appointmentId) return
|
||||
context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse,
|
||||
appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0
|
||||
&& !Object.hasOwn(appointment, 'receivableAmount')
|
||||
&& !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS)
|
||||
const path = `${TRUCKS}/${appointmentId}`
|
||||
const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER')
|
||||
attachmentForm.append('file', new Blob([`${context.marker}-DISPATCH_ORDER`], { type: 'application/pdf' }),
|
||||
`dispatch-order-${identity.suffix}.pdf`)
|
||||
const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
|
||||
attachmentForm, context.token)
|
||||
const uploadedAttachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS)
|
||||
const uploadedAttachmentId = id(uploadedAttachment?.id); const uploadedFileId = id(uploadedAttachment?.fileId)
|
||||
context.assert(MODULE, '拖车附件类型仅使用运营附件而非订舱提单', uploadAttachmentResponse,
|
||||
Boolean(uploadedAttachmentId && uploadedFileId) && uploadedAttachment?.attachmentType === 'DISPATCH_ORDER',
|
||||
`attachmentId=${uploadedAttachmentId || 'missing'}, fileId=${uploadedFileId || 'missing'}`, OPTIONS)
|
||||
const deleteUploadedResponse = await request(context, 'DELETE', `${path}/attachments/${uploadedAttachmentId}`)
|
||||
context.apiSuccess(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, '', OPTIONS)
|
||||
const referenceResponse = await request(context, 'POST', `${path}/attachments`,
|
||||
{ fileId: uploadedFileId, attachmentType: 'DISPATCH_ORDER' })
|
||||
const referencedAttachment = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referenceResponse, '', OPTIONS)
|
||||
const referencedAttachmentId = id(referencedAttachment?.id)
|
||||
const attachmentListResponse = await request(context, 'GET', `${path}/attachments`)
|
||||
const attachmentList = context.apiSuccess(MODULE, '查询拖车运营附件列表', attachmentListResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', attachmentListResponse,
|
||||
Array.isArray(attachmentList) && attachmentList.some((item) => id(item?.id) === referencedAttachmentId
|
||||
&& item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${referencedAttachmentId || 'missing'}`, OPTIONS)
|
||||
const deleteReferencedResponse = await request(context, 'DELETE', `${path}/attachments/${referencedAttachmentId}`)
|
||||
context.apiSuccess(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, '', OPTIONS)
|
||||
const usdResponse = await request(context, 'PUT', path,
|
||||
{ ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) })
|
||||
const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse,
|
||||
usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34
|
||||
&& !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS)
|
||||
const duplicate = await request(context, 'POST', TRUCKS, base)
|
||||
context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS)
|
||||
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
||||
const submitBody = { expectedVersion: Number(usdAppointment?.version), reason: '幂等提交' }
|
||||
const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
||||
const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS)
|
||||
const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
||||
const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS)
|
||||
context.assert(MODULE, '提交幂等重放零漂移', replay,
|
||||
id(firstData?.id) === id(replayData?.id) && Number(firstData?.version) === Number(replayData?.version),
|
||||
`firstVersion=${firstData?.version}, replayVersion=${replayData?.version}`, OPTIONS)
|
||||
version = Number(firstData?.version)
|
||||
const conflict = await request(context, 'POST', `${path}/submit`, { expectedVersion: version, reason: '异载荷' },
|
||||
{ 'Idempotency-Key': submitKey })
|
||||
context.assert(MODULE, '同键重放版本零漂移', replay, Number(firstData?.version) === Number(replayData?.version), '', OPTIONS)
|
||||
const conflict = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, { 'Idempotency-Key': submitKey })
|
||||
context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS)
|
||||
const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: version, reason: '派车' },
|
||||
{ 'Idempotency-Key': `${context.marker}-dispatch` })
|
||||
const dispatch = await request(context, 'POST', `${path}/dispatch`,
|
||||
{ expectedVersion: Number(firstData?.version), reason: '派车' },
|
||||
{ 'Idempotency-Key': `${context.marker}-truck-dispatch`.slice(0, 128) })
|
||||
const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS)
|
||||
context.assert(MODULE, '派车状态与稳定字段完整', dispatch,
|
||||
dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0,
|
||||
`status=${dispatched?.status || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '派车命令返回状态身份与版本正确', dispatch,
|
||||
dispatched?.status === 'DISPATCHED' && id(dispatched?.id) === appointmentId
|
||||
&& Number(dispatched?.version) > Number(firstData?.version),
|
||||
`id=${id(dispatched?.id) || 'missing'}, status=${dispatched?.status || 'missing'}, version=${dispatched?.version ?? 'missing'}`, OPTIONS)
|
||||
const detailResponse = await request(context, 'GET', path); const detail = data(detailResponse)
|
||||
context.apiSuccess(MODULE, '派车后回查拖车完整详情', detailResponse, '', OPTIONS)
|
||||
const detailGaps = truckAppointmentFieldGaps(detail)
|
||||
context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse,
|
||||
detail?.status === 'DISPATCHED' && detailGaps.length === 0
|
||||
&& !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName')
|
||||
&& !Object.hasOwn(detail || {}, 'receivableAmount')
|
||||
&& detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34
|
||||
&& id(detail?.oceanBookingId) === identity.oceanBookingId
|
||||
&& id(detail?.containerBookingId) === identity.containerBookingId,
|
||||
`missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED',
|
||||
oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url)
|
||||
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java')
|
||||
const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java')
|
||||
const warehouseClient = read('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java')
|
||||
const serviceTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java')
|
||||
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
|
||||
const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts')
|
||||
const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
|
||||
@@ -112,3 +113,16 @@ test('historical pallet snapshot remains read-only visible when it is no longer
|
||||
assert.match(cards, /row\.palletTareWeightKg/)
|
||||
assert.match(adminBehavior, /(?:historical|history|历史|失去候选|no longer eligible).{0,220}(?:palletCode|托盘编码|TP-)/is)
|
||||
})
|
||||
|
||||
test('backend detail reads the stored historical pallet snapshot without revalidating current eligibility', () => {
|
||||
assert.match(serviceTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/)
|
||||
assert.match(serviceTests, /verify\(warehousePallets, never\(\)\)\.palletSnapshot/)
|
||||
assert.match(serviceTests, /verify\(warehousePallets, never\(\)\)\.palletSnapshots/)
|
||||
})
|
||||
|
||||
test('backend evidence fails closed for Warehouse non-2xx and network errors', () => {
|
||||
assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse/)
|
||||
assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNetworkError/)
|
||||
assert.match(serviceTests, /FeignException/)
|
||||
assert.match(serviceTests, /RetryableException/)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url)
|
||||
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java')
|
||||
const pageResult = read('../../common-core/src/main/java/com/baiyue/common/core/api/PageResult.java')
|
||||
const longJackson = read('../../common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java')
|
||||
const serviceTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java')
|
||||
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
|
||||
const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts')
|
||||
const adminApiTests = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.test.ts')
|
||||
@@ -84,3 +85,9 @@ test('Admin behavior separately covers a non-2xx HTTP or network error', () => {
|
||||
test('Admin aborts and ignores an expired candidate response', () => {
|
||||
assert.match(adminBehavior, /in-flight pallet request[\s\S]{0,900}signal\?\.aborted[\s\S]{0,300}过期托盘/)
|
||||
})
|
||||
|
||||
test('backend preserves failure evidence for Warehouse non-2xx and network errors', () => {
|
||||
assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse/)
|
||||
assert.match(serviceTests, /weightPalletOptionsFailsClosedOnWarehouseNetworkError/)
|
||||
assert.match(serviceTests, /仓库服务暂不可用/)
|
||||
})
|
||||
|
||||
@@ -92,6 +92,7 @@ $clientAgents = Read-RequiredText $clientAgentsPath
|
||||
$pdaAgents = Read-RequiredText $pdaAgentsPath
|
||||
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
|
||||
$backendAgents = Read-RequiredText $backendAgentsPath
|
||||
$scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md')
|
||||
$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md')
|
||||
$mapperScanContract = Read-RequiredText (Join-Path $backendRoot 'scripts/mapper-scan-contract.mjs')
|
||||
|
||||
@@ -100,19 +101,33 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机
|
||||
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
|
||||
Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口'
|
||||
Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁'
|
||||
Assert-Contains $rootAgents '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
|
||||
Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引'
|
||||
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
|
||||
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
|
||||
Assert-Contains $clientAgents '不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API' 'Client 头程无兼容边界'
|
||||
Assert-Contains $backendAgents '所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`' '后端 Mapper 注册规则'
|
||||
Assert-Contains $backendAgents 'Mapper 新增、移动、Bean 缺失与扫描遗漏' '后端 Mapper 快速路由'
|
||||
Assert-Contains $backendAgents '脚本管理、全量验证、测试造数与诊断' '后端脚本管理快速路由'
|
||||
Assert-Contains $backendAgents 'scripts/it-test-wait-split-bulk.mjs' '后端批量待分柜造数入口'
|
||||
Assert-Contains $backendAgents 'scripts/create-pending-review-orders.mjs' '后端待审核订单造数入口'
|
||||
Assert-Contains $scriptsAgents 'admin-test-env-acceptance.mjs' '脚本指引 Admin 全量入口'
|
||||
Assert-Contains $scriptsAgents 'client-test-env-acceptance.mjs' '脚本指引 Client 全量入口'
|
||||
Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁'
|
||||
Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界'
|
||||
Assert-Path 'baiyue-firstleg/scripts/AGENTS.md'
|
||||
Assert-Path 'baiyue-firstleg/scripts/README.md'
|
||||
Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs'
|
||||
Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同'
|
||||
Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同'
|
||||
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
|
||||
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
|
||||
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
|
||||
Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
|
||||
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
|
||||
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
|
||||
Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界'
|
||||
Assert-Contains $warehouseAgents 'scripts/warehouse-public-master-data-contract.test.mjs' 'Warehouse 局部脚本入口'
|
||||
Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI'
|
||||
Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI'
|
||||
Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或以内部数据库 ID 作为业务编号回退' '根业务编号展示边界'
|
||||
@@ -125,6 +140,22 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md')
|
||||
Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码'
|
||||
Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口'
|
||||
Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口'
|
||||
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
|
||||
$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml')
|
||||
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
|
||||
Assert-Contains $rootAgents '拖车预约由已放舱订舱拥有,不按客户归属' '根订舱拥有拖车语义'
|
||||
Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
|
||||
Assert-Contains $adminAgents '订舱拥有的拖车预约' 'Admin 拖车快速入口'
|
||||
Assert-Contains $adminAgents '不选择或提交客户' 'Admin 拖车无客户语义'
|
||||
Assert-Contains $backendAgents '订舱拥有的拖车' '后端拖车快速入口'
|
||||
Assert-Contains $firstlegAgents '请求、实体、查询和附件不得恢复 `customerId/customerName`' 'Firstleg 拖车无客户边界'
|
||||
Assert-Contains $firstlegAgents '同一真实柜通过查询锁与唯一约束最多保留一条非取消预约' 'Firstleg 拖车唯一有效预约'
|
||||
Assert-Contains $fileAgents '`businessType=TRUCK_APPOINTMENT_ATTACHMENT`' 'File 拖车平台附件入口'
|
||||
Assert-Contains $truckOpenApi 'required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]' '拖车请求无客户字段'
|
||||
Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段'
|
||||
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
|
||||
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
|
||||
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
|
||||
$mapperModuleAgents = @(
|
||||
@{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' },
|
||||
@{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' },
|
||||
@@ -437,6 +468,10 @@ $criticalPaths = @(
|
||||
'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue',
|
||||
'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts',
|
||||
'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue',
|
||||
'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue',
|
||||
'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue',
|
||||
'apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue',
|
||||
'apps/admin-web/src/modules/trucking/api/platformTrucking.ts',
|
||||
'apps/admin-web/src/core/routing/navigationRecovery.ts',
|
||||
'apps/admin-web/src/core/routing/navigationRecovery.test.ts',
|
||||
'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts',
|
||||
@@ -530,6 +565,10 @@ $criticalPaths = @(
|
||||
'baiyue-firstleg/firstleg/src/main/resources/openapi/loading-lists-v1.yaml',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java',
|
||||
'baiyue-firstleg/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml',
|
||||
'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml',
|
||||
'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java',
|
||||
'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java',
|
||||
@@ -710,5 +749,5 @@ if ($failures.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、订舱拥有拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||
exit 0
|
||||
|
||||
@@ -16,22 +16,21 @@ import { resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY
|
||||
?? 'http://8.163.30.128:9000')
|
||||
const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME
|
||||
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
||||
const clientUsername = args['client-username'] ?? process.env.MERCHANT_TEST_USERNAME
|
||||
?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '')
|
||||
const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD
|
||||
const clientPassword = args['client-password'] ?? process.env.MERCHANT_TEST_PASSWORD
|
||||
?? await promptSecret('二期客户端密码')
|
||||
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
|
||||
?? await promptText('平台夹具查询账号', 'platform_admin')
|
||||
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
|
||||
const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME
|
||||
?? await promptText('平台夹具查询账号', '')
|
||||
const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD
|
||||
?? await promptSecret('平台夹具查询密码')
|
||||
const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null
|
||||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports')
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000)
|
||||
|
||||
if (!clientUsername || !clientPassword || !platformPassword) {
|
||||
console.error('An onboarded Client account and platform credentials are required')
|
||||
if (!gateway || !clientUsername || !clientPassword || !platformUsername || !platformPassword) {
|
||||
console.error('ADMIN_TEST_GATEWAY, an onboarded Client account, and platform credentials are required')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*
|
||||
* Run directly and enter the platform/client test credentials when prompted.
|
||||
* Optional environment variables for CI:
|
||||
* CLIENT_TEST_GATEWAY, PLATFORM_USERNAME, EXISTING_CLIENT_USERNAME,
|
||||
* PLATFORM_PASSWORD, EXISTING_CLIENT_PASSWORD, CLIENT_TEST_REPORT_DIR
|
||||
* ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
|
||||
* MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR
|
||||
*
|
||||
* Example:
|
||||
* node scripts/client-test-env-acceptance.mjs
|
||||
@@ -18,20 +18,20 @@ import { resolve } from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? 'http://8.163.30.128:9000')
|
||||
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
|
||||
?? await promptText('平台审核账号', 'platform_admin')
|
||||
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
|
||||
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
||||
const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME
|
||||
?? await promptText('平台审核账号', '')
|
||||
const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD
|
||||
?? await promptSecret('平台审核密码')
|
||||
const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME
|
||||
const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME
|
||||
?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '')
|
||||
const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD
|
||||
const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD
|
||||
?? await promptSecret('跨客户隔离测试密码')
|
||||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||||
|
||||
if (!platformPassword || !existingClientUsername || !existingClientPassword) {
|
||||
console.error('Platform password and an independently onboarded client account are required')
|
||||
if (!gateway || !platformUsername || !platformPassword || !existingClientUsername || !existingClientPassword) {
|
||||
console.error('ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||||
@@ -1290,7 +1290,7 @@ async function orderLifecycleModule() {
|
||||
async function isolationModule() {
|
||||
if (!existingClientUsername || !existingClientPassword) {
|
||||
recordSkipped('isolation', 'cross-client order access is rejected',
|
||||
'Set EXISTING_CLIENT_USERNAME and EXISTING_CLIENT_PASSWORD to enable this check')
|
||||
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
|
||||
return
|
||||
}
|
||||
const login = await apiStep('isolation', 'existing client login', {
|
||||
|
||||
@@ -6,11 +6,14 @@ import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, ra
|
||||
|
||||
const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('uses the fixed merchant account and stops at pending review', () => {
|
||||
assert.ok(source.includes("USERNAME: '17512095155'"), 'fixed username is missing')
|
||||
assert.ok(/PASSWORD:\s*'[^']*'/.test(source), 'CONFIG.PASSWORD is missing')
|
||||
assert.ok(/COUNT:\s*\d+/.test(source), 'CONFIG.COUNT is missing')
|
||||
assert.match(source, /COUNT:\s*50/)
|
||||
test('uses the configured merchant account and stops at pending review', () => {
|
||||
assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/)
|
||||
assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/)
|
||||
assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/)
|
||||
assert.match(source, /const DEFAULT_COUNT = 50/)
|
||||
assert.doesNotMatch(source, /const CONFIG\s*=/)
|
||||
assert.doesNotMatch(source, /8\.163\.30\.128/)
|
||||
assert.doesNotMatch(source, /PASSWORD:\s*'[^']+'/)
|
||||
assert.match(source, /randomInt\(1, 31\)/)
|
||||
assert.match(source, /loadPricedRoutes/)
|
||||
assert.match(source, /destinationWarehouseCode/)
|
||||
@@ -28,6 +31,7 @@ test('uses the fixed merchant account and stops at pending review', () => {
|
||||
})
|
||||
|
||||
test('supports a bounded fixed order count', () => {
|
||||
assert.equal(validateCount(undefined), 50)
|
||||
assert.equal(validateCount('1'), 1)
|
||||
assert.equal(validateCount('10'), 10)
|
||||
assert.throws(() => validateCount('0'), /between 1 and 100/)
|
||||
|
||||
@@ -29,13 +29,7 @@ export const PRODUCT_CATALOG = [
|
||||
{ cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 },
|
||||
]
|
||||
|
||||
// Local test-environment settings. Keep PASSWORD empty in committed code.
|
||||
const CONFIG = {
|
||||
GATEWAY: 'http://8.163.30.128:9000',
|
||||
USERNAME: '17512095155',
|
||||
PASSWORD: 'M4mmp4YWcH5DiYPT',
|
||||
COUNT: 50,
|
||||
}
|
||||
const DEFAULT_COUNT = 50
|
||||
|
||||
export function parseArguments(values) {
|
||||
const options = {}
|
||||
@@ -58,7 +52,7 @@ export function parseArguments(values) {
|
||||
}
|
||||
|
||||
export function validateCount(value) {
|
||||
const count = Number(value ?? CONFIG.COUNT)
|
||||
const count = Number(value ?? DEFAULT_COUNT)
|
||||
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
||||
throw new Error(`--count must be an integer between 1 and ${MAX_COUNT}`)
|
||||
}
|
||||
@@ -342,12 +336,13 @@ function printHelp() {
|
||||
node scripts/create-pending-review-orders.mjs --gateway <url> --count <1-${MAX_COUNT}> [--same-channel]
|
||||
|
||||
Environment:
|
||||
CLIENT_TEST_GATEWAY Test environment Gateway URL
|
||||
ADMIN_TEST_GATEWAY Test environment Gateway URL
|
||||
MERCHANT_TEST_PASSWORD Password for the configured fixed account
|
||||
MERCHANT_TEST_USERNAME Optional username override
|
||||
MERCHANT_TEST_USERNAME Username for the configured fixed account
|
||||
PENDING_REVIEW_ORDER_COUNT Optional order count, defaults to ${DEFAULT_COUNT}
|
||||
PENDING_ORDER_REPORT Optional JSON report path
|
||||
|
||||
The GATEWAY, USERNAME, PASSWORD, and COUNT fields can also be filled in CONFIG at the top of this file.`)
|
||||
Command-line arguments override environment variables. Credentials must not be written into this file.`)
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
@@ -356,13 +351,13 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
printHelp()
|
||||
return 0
|
||||
}
|
||||
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? CONFIG.GATEWAY)
|
||||
if (!gateway) throw new Error('Gateway is required via CONFIG.GATEWAY, --gateway, or CLIENT_TEST_GATEWAY')
|
||||
const count = validateCount(args.count)
|
||||
const username = args.username ?? process.env.MERCHANT_TEST_USERNAME ?? CONFIG.USERNAME
|
||||
const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD ?? CONFIG.PASSWORD
|
||||
if (!username) throw new Error('Fixed merchant username is required in CONFIG.USERNAME')
|
||||
if (!password) throw new Error('Merchant password is required in CONFIG.PASSWORD or MERCHANT_TEST_PASSWORD')
|
||||
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
||||
if (!gateway) throw new Error('Gateway is required via --gateway or ADMIN_TEST_GATEWAY')
|
||||
const count = validateCount(args.count ?? process.env.PENDING_REVIEW_ORDER_COUNT)
|
||||
const username = args.username ?? process.env.MERCHANT_TEST_USERNAME
|
||||
const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD
|
||||
if (!username) throw new Error('Merchant username is required via --username or MERCHANT_TEST_USERNAME')
|
||||
if (!password) throw new Error('Merchant password is required via --password or MERCHANT_TEST_PASSWORD')
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) throw new Error('--timeout-ms must be at least 1000')
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
const client = read('./client-test-env-acceptance.mjs')
|
||||
const clientPhase2 = read('./client-phase2-test-env-acceptance.mjs')
|
||||
const pendingOrders = read('./create-pending-review-orders.mjs')
|
||||
const readme = read('./README.md')
|
||||
const agents = read('./AGENTS.md')
|
||||
|
||||
const activeHttpScripts = [client, clientPhase2, pendingOrders]
|
||||
const removedEnvironmentVariables = [
|
||||
'CLIENT_TEST_GATEWAY', 'CLIENT_PHASE2_TEST_GATEWAY',
|
||||
'PLATFORM_USERNAME', 'PLATFORM_PASSWORD',
|
||||
'EXISTING_CLIENT_USERNAME', 'EXISTING_CLIENT_PASSWORD',
|
||||
'CLIENT_PHASE2_USERNAME', 'CLIENT_PHASE2_PASSWORD',
|
||||
]
|
||||
|
||||
test('active Client and data-generation scripts share the canonical environment variables', () => {
|
||||
for (const source of activeHttpScripts) {
|
||||
assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/)
|
||||
assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/)
|
||||
assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/)
|
||||
}
|
||||
assert.match(client, /process\.env\.ADMIN_TEST_USERNAME/)
|
||||
assert.match(client, /process\.env\.ADMIN_TEST_PASSWORD/)
|
||||
assert.match(clientPhase2, /process\.env\.ADMIN_TEST_USERNAME/)
|
||||
assert.match(clientPhase2, /process\.env\.ADMIN_TEST_PASSWORD/)
|
||||
})
|
||||
|
||||
test('active scripts no longer carry the retired variables, fixed Gateway, or source password config', () => {
|
||||
for (const source of activeHttpScripts) {
|
||||
for (const variable of removedEnvironmentVariables) assert.doesNotMatch(source, new RegExp(`process\\.env\\.${variable}`))
|
||||
assert.doesNotMatch(source, /8\.163\.30\.128/)
|
||||
}
|
||||
assert.doesNotMatch(pendingOrders, /const CONFIG\s*=/)
|
||||
assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/)
|
||||
})
|
||||
|
||||
test('documentation names both external environment files and requires explicit selection', () => {
|
||||
for (const source of [readme, agents]) {
|
||||
assert.match(source, /chongchong-global-dev-acceptance\.env/)
|
||||
assert.match(source, /chongchong-global-test-acceptance\.env/)
|
||||
assert.match(source, /--env-file/)
|
||||
}
|
||||
assert.match(readme, /不会自行查找或加载上述文件/)
|
||||
assert.match(readme, /ADMIN_OVERSEAS_GATEWAY/)
|
||||
})
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
| 国内整柜业务 | `domesticcontainer/controller/PlatformDomesticContainerController.java`、`domesticcontainer/service/DomesticContainerService.java`;契约见 `src/main/resources/openapi/domestic-container-v1.yaml` |
|
||||
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
|
||||
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 测试/验证 | `src/test/java/com/baiyue/warehouse/`;`mvn -pl warehouse -am test` |
|
||||
| 测试/验证 | Java 测试 `src/test/java/com/baiyue/warehouse/`,执行 `mvn -pl warehouse -am test`;公共 FBA 仓 CSV 与 SQL 离线合同 `scripts/warehouse-public-master-data-contract.test.mjs`,从后端根执行 `node --test warehouse/scripts/warehouse-public-master-data-contract.test.mjs`;局部说明见 `scripts/README.md` |
|
||||
|
||||
## 修改约束
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ components:
|
||||
type: array
|
||||
maxItems: 100
|
||||
items: { $ref: '#/components/schemas/PalletSnapshot' }
|
||||
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]*)$' }] }
|
||||
|
||||
@@ -20,6 +20,10 @@ class PalletOpenApiContractTest {
|
||||
.contains("maximum: 100")
|
||||
.contains("maxItems: 100")
|
||||
.contains("PalletSnapshotPageResponse:")
|
||||
.contains("page: { oneOf:")
|
||||
.contains("size: { oneOf:")
|
||||
.contains("total: { oneOf:")
|
||||
.contains("pages: { oneOf:")
|
||||
.contains("required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]")
|
||||
.contains("additionalProperties: false")
|
||||
.doesNotContain("tenantId:")
|
||||
|
||||
Reference in New Issue
Block a user