This commit is contained in:
2026-09-04 15:19:45 +08:00
parent 2c21d27f61
commit 4aed53a8af
18 changed files with 162 additions and 84 deletions
+2 -2
View File
@@ -18,7 +18,7 @@
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`CartonMeasurementService.java`、`CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`CartonMeasurementService.java`、`CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
@@ -81,7 +81,7 @@
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 预留并确认整票库位块;有效体积严格依次取墨家/人工测量实测体积、测量批次冻结申报体积、Order 最新客户申报体积,全部无效才拒绝,不得把后两级回退冒充实测。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId` 不得变化。测量提交只校验并复用已存在权威块,不得首次分配。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 - 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 - 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 - PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
+1
View File
@@ -15,6 +15,7 @@
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | | 批量待分柜造数 | `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` 只做离线合同 | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写测试环境,必须有当前轮授权 |
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
@@ -111,12 +111,10 @@ export function portScheduleFieldGaps(record) {
} }
export function truckAppointmentFieldGaps(record) { export function truckAppointmentFieldGaps(record) {
return gaps(record, { const result = gaps(record, {
id: isPositiveId, id: isPositiveId,
appointmentNo: isNonBlank, appointmentNo: isNonBlank,
oceanBookingId: isPositiveId, oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: (value) => value == null || isPositiveId(value),
warehouseId: (value) => value == null || isPositiveId(value), warehouseId: (value) => value == null || isPositiveId(value),
plannedLoadingAt: isIsoDateTime, plannedLoadingAt: isIsoDateTime,
status: isNonBlank, status: isNonBlank,
@@ -126,6 +124,10 @@ export function truckAppointmentFieldGaps(record) {
currency: (value) => value === 'RMB' || value === 'USD', currency: (value) => value === 'RMB' || value === 'USD',
attachmentCount: isNonNegativeNumber, attachmentCount: isNonNegativeNumber,
}) })
for (const field of ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) {
if (Object.hasOwn(record || {}, field)) result.push(`forbidden:${field}`)
}
return result
} }
export function customsCaseFieldGaps(record) { export function customsCaseFieldGaps(record) {
@@ -54,7 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], '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'], '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'], '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', 'currency', 'plannedLoadingAt', 'attachmentCount'], 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'oceanBookingNo', 'billOfLadingNo', 'warehouseName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'],
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
} }
for (const [relative, fields] of Object.entries(expectations)) { for (const [relative, fields] of Object.entries(expectations)) {
@@ -87,7 +87,7 @@ 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'], [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'], [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'], [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: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, 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'], [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) { for (const [validator, sample, options, missing] of samples) {
@@ -97,6 +97,11 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
target[missing] = '' target[missing] = ''
assert.ok(validator(invalid, options).includes(missing)) assert.ok(validator(invalid, options).includes(missing))
} }
assert.ok(truckAppointmentFieldGaps({
id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id,
carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB',
plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0, containerNo: 'MSCU1234567',
}).includes('forbidden:containerNo'))
}) })
test('字段断言接入正式生产模块和默认全量入口', () => { test('字段断言接入正式生产模块和默认全量入口', () => {
@@ -38,16 +38,13 @@ test('deduplication uses stable event identity and never identical display text'
assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5']) assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5'])
}) })
test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => { test('backend never appends a generic STATUS_UPDATE and current operation replay is read-only', () => {
assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s) 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, /private Plan replay\([\s\S]{0,900}sameRequestHash/)
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/)
const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()') assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/)
const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10) assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/)
const replayBlock = smartLoadingTest.slice(replayStart, replayEnd) assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/)
assert.notEqual(replayStart, -1)
assert.match(replayBlock, /never\(\)\)\.insert/)
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
}) })
test('operationId is projected end-to-end for identity-based frontend deduplication', () => { test('operationId is projected end-to-end for identity-based frontend deduplication', () => {
@@ -37,7 +37,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) assert.match(acceptance, /merchant-username和merchant-password必须同时提供/)
assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/)
assert.match(acceptance, /wait-split-count必须是1-100的整数/) assert.match(acceptance, /wait-split-count必须是1-100的整数/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) assert.match(acceptance, /fixtureModules = new Set\(\['merchant-qualification-fixture', 'orders', 'inbound-measurement',[\s\S]{0,180}'coload-groups'[\s\S]{0,180}'firstleg-container-fee-allocation'/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块/)
assert.match(acceptance, /createAdditionalWaitSplitFixtures/) assert.match(acceptance, /createAdditionalWaitSplitFixtures/)
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
@@ -91,9 +91,10 @@ test('a different operationId is rejected while the completion command is proces
test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => { test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => {
assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/) assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/)
assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/) assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/)
assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/) assert.match(settlementTests, /repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain/)
assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/) assert.match(settlementTests, /splits\.appliedBatch\(10001L, "PDA-SPLIT-1-31"\)/)
assert.match(settlementTests, /targetBusinessId\(\) == 999L/) assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.transferBlock/)
assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.releaseBlock/)
assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/)
}) })
@@ -64,7 +64,7 @@ test('active overview photos download through controlled paths while void histor
]) assert.equal(controlledPhotoDownload(path, response), false) ]) assert.equal(controlledPhotoDownload(path, response), false)
}) })
test('truck attachment API and Admin upload-reference-delete flow are frozen', () => { test('truck attachment API reference flow and Admin upload-delete flow are frozen', () => {
assert.match(truckApi, /referenceTruckAppointmentAttachment/) assert.match(truckApi, /referenceTruckAppointmentAttachment/)
assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /uploadTruckAppointmentAttachment/)
assert.match(truckApi, /deleteTruckAppointmentAttachment/) assert.match(truckApi, /deleteTruckAppointmentAttachment/)
@@ -91,7 +91,8 @@ test('existing container number replacement keeps container plan and PDA identit
}) })
test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => { test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/) assert.match(containerLoadingAcceptance, /分配后自动建立预装关联且计划可读/)
assert.match(containerLoadingAcceptance, /无号 push 后预装清单可见/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/) assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/) assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
@@ -30,8 +30,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap
const convergence = read('phase3-pda-convergence.mjs') const convergence = read('phase3-pda-convergence.mjs')
const dispatch = read('phase3-outbound-dispatch.mjs') const dispatch = read('phase3-outbound-dispatch.mjs')
const aggregate = read('phase3-outbound-aggregate.mjs') const aggregate = read('phase3-outbound-aggregate.mjs')
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) assert.match(loading, /增量后旧 inventory token 明确 A0409 并要求刷新[\s\S]{0,500}codeOf\(staleIncrementBatch\) === 'A0409'/)
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) assert.match(loading, /增量旧 token 冲突后 plan\/task\/items 零写入/)
assert.match(convergence, /fixture\?\.dynamicIncrementVerified !== true[\s\S]{0,500}不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护/)
assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/) assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/)
assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) assert.match(dispatch, /'CUSTOMS_CLEARANCE'/)
@@ -14,11 +14,13 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m
assert.ok(`P3B-${primary}`.length <= 64) assert.ok(`P3B-${primary}`.length <= 64)
}) })
test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => { test('phase3 loading uses the collision-safe marker suffix for generated business identifiers', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile( const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/) assert.match(source, /const suffix = markerSuffix\(context\.marker\)/)
assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/) assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/)
assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/) assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/)
assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/) assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /增量后旧 inventory token 明确 A0409 并要求刷新/)
assert.match(source, /增量旧 token 冲突后 plan\/task\/items 零写入/)
}) })
@@ -18,7 +18,8 @@ test('each remainder uses a simulation linked to a released booking requirement'
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/) assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/)
const releaseStart = source.indexOf('const releaseResponse') const releaseStart = source.indexOf('const releaseResponse')
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart)) const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/) assert.match(source, /bookingDocumentForm\.append\('expectedVersion', String\(Number\(bookingCreated\?\.oceanBooking\?\.version\)\)\)/)
assert.match(releaseBlock, /expectedVersion: releaseVersion/)
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/) assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
@@ -88,7 +88,8 @@ test('container loading follows push, claim, real pick, completed sequence', ()
test('bridge loading confirms preload before creating and claiming the PDA task', () => { test('bridge loading confirms preload before creating and claiming the PDA task', () => {
const value = source('./phase3-loading.mjs') const value = source('./phase3-loading.mjs')
assertOrder(value, [ assertOrder(value, [
'无真实柜号推送预装成功并冻结柜次明细', '分配后自动建立预装关联且柜次计划可读',
'无号推送后预装清单正式可见',
'无号 push 成功后 PDA 查询明确 A0404', '无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移', 'push exact replay 对计划版本历史关联和任务零漂移',
'无号且未关联订舱仍无 PDA 任务', '无号且未关联订舱仍无 PDA 任务',
@@ -111,7 +112,7 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
assert.match(value, /计划级确认同 key 异参返回冲突/) assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装成功并冻结柜次明细'), value.indexOf('const linkResponse')), assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且柜次计划可读'), value.indexOf('const linkResponse')),
/\/transport/) /\/transport/)
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/) assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
@@ -159,22 +160,22 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi
assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/)
}) })
test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { test('automatic preload bridge repeated reads preserve the complete pre-PDA snapshot', () => {
for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) {
const value = source(file) const value = source(file)
assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/) assert.match(value, /const pushRequest = Object\.freeze\(\{ path: `\$\{(?:smartLoadingBase|SMART)\}\/plans\/\$\{(?:loadingPlanId|planId)\}` \}\)/)
assert.equal((value.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(value, /push exact replay[\s\S]*零漂移/) assert.match(value, /push exact replay[\s\S]*零漂移/)
assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/) assert.match(value, /自动关联计划重复读取稳定[\s\S]*自动关联计划再次读取稳定/)
assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) assert.match(value, /重放后回查柜次计划[\s\S]*重放后回查预装历史[\s\S]*重放后回查 bridge/)
assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/)
assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/)
assert.match(value, /projectPushPreloadStability/) assert.match(value, /projectPushPreloadStability/)
assert.match(value, /diffPushPreloadStability/) assert.match(value, /diffPushPreloadStability/)
assert.match(value, /首次 push 后读取事务完成态柜次计划/) assert.match(value, /首次 push 后读取事务完成态柜次计划/)
assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/) assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/)
assert.match(value, /plan: postPushPlan, detail:/) assert.match(value, /plan: postPushPlan, detail:/)
assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/)
assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/) assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/)
assert.doesNotMatch(value, /\/plans\/[^'`]*\/push-preload/)
assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/) assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/)
} }
}) })
@@ -215,15 +216,15 @@ test('push stability projection sorts unordered facts and reports persisted fiel
]) ])
}) })
test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { test('assignment transaction establishes preload and bridge before PDA synchronization', () => {
const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay')) const assign = service.slice(service.indexOf('public Plan assign('), service.indexOf('public Plan assignInbound('))
assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/) assert.match(service, /@Transactional[\s\S]{0,300}public Plan assign\(/)
assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/) assert.match(assign, /splitPreloadService\.ensure(?:Pending|ForRunning)[\s\S]*touch\(plan[\s\S]*history\([\s\S]*loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/)
assert.doesNotMatch(push, /loadingPickService\.beginIfReady/) assert.match(serviceTest, /addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus/)
assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/) assert.match(serviceTest, /inOrder\(loadingPickService, splitPreloadService, repository, loadingModelBridge\)/)
assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/) assert.match(serviceTest, /order\.verify\(loadingModelBridge\)\.bindIncrementalAllocation/)
}) })
test('finalize rechecks the same unified order detail and list as LOADED', () => { test('finalize rechecks the same unified order detail and list as LOADED', () => {
@@ -274,9 +275,9 @@ test('container loading arranges a simulation before linking a released booking
assertOrder(value, [ assertOrder(value, [
"const simulationResponse = await request(context, 'POST', CONTAINERS", "const simulationResponse = await request(context, 'POST', CONTAINERS",
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
"const planResponse = await request(context, 'POST', `${SMART}/plans`", "const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/",
"const assignResponse = await request", "const assignResponse = await request",
'无真实柜号推送预装计划成功', '分配后自动建立预装关联且计划可读',
"const bookingCreate = await request", "const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
]) ])
@@ -304,10 +305,10 @@ test('container loading arranges a simulation before linking a released booking
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')), assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且计划可读'), value.indexOf('const linkResponse')),
/\/transport/) /\/transport/)
assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/) assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(value, /无真实柜号推送预装计划成功/) assert.match(value, /分配后自动建立预装关联且计划可读/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(value, /confirmationStatus === 'CONFIRMED'/) assert.match(value, /confirmationStatus === 'CONFIRMED'/)
@@ -398,7 +399,7 @@ test('container loading permits manual cross-channel allocation and keeps stable
'手工装柜允许两个真实有效渠道之间跨渠道分配', '手工装柜允许两个真实有效渠道之间跨渠道分配',
'跨渠道分配验收后释放入仓码供主流程继续', '跨渠道分配验收后释放入仓码供主流程继续',
'人工分配本轮候选货物', '人工分配本轮候选货物',
'无真实柜号推送预装计划成功', '分配后自动建立预装关联且计划可读',
'无号 push 成功后 PDA 查询 A0404', '无号 push 成功后 PDA 查询 A0404',
'无号且未关联订舱不生成 PDA 任务', '无号且未关联订舱不生成 PDA 任务',
'订舱放舱为 RELEASED 且不预生成真实货柜', '订舱放舱为 RELEASED 且不预生成真实货柜',
@@ -431,16 +432,18 @@ test('container loading permits manual cross-channel allocation and keeps stable
assert.doesNotMatch(batch, /containerNo:/) assert.doesNotMatch(batch, /containerNo:/)
}) })
test('preload and loading versions use immediate operation baselines and exact push replay', () => { test('preload and loading versions use immediate baselines and stable automatic bridge reads', () => {
const container = source('./phase3-container-loading.mjs') const container = source('./phase3-container-loading.mjs')
assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) assert.match(container, /const pushRequest = Object\.freeze\(\{ path: `\$\{SMART\}\/plans\/\$\{planId\}` \}\)/)
assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.equal((container.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(container, /自动关联保持 EDITING 且无需额外推送版本/)
assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/)
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
const loading = source('./phase3-loading.mjs') const loading = source('./phase3-loading.mjs')
assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) assert.match(loading, /const pushRequest = Object\.freeze\(\{ path: `\$\{smartLoadingBase\}\/plans\/\$\{loadingPlanId\}` \}\)/)
assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.equal((loading.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/)
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/)
@@ -524,18 +527,19 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/)
assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/)
assert.match(loading, /超权威总箱数失败后计划和明细零写入/) assert.match(loading, /超权威总箱数失败后计划和明细零写入/)
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) assert.match(loading, /taskItemsSignature\(taskAfterStale\) === taskItemsSignature\(incrementedTask\)/)
assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/)
assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/)
assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2)
assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/)
assertOrder(loading, [ assertOrder(loading, [
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
'库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放保持待人工确认',
'回查下游门禁前原子 Batch 审计', '回查下游门禁前原子 Batch 审计',
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
'LOADING 阶段向同一 PDA task 增加入库单',
'增量后旧 inventory token 明确 A0409 并要求刷新',
'增量旧 token 冲突后 plan/task/items 零写入',
'重新增量后刷新 inventory snapshot',
'刷新后完成动态新增项拣货',
'报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING',
]) ])
assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /complete-before-downstream/)
@@ -629,10 +633,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/) assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/)
assert.match(serviceCenter, /oceanBookingId: ocean\.id/) assert.match(serviceCenter, /oceanBookingId: ocean\.id/)
assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/) const servicePayload = serviceCenter.slice(serviceCenter.indexOf('const payload = {'),
serviceCenter.indexOf('const response =', serviceCenter.indexOf('const payload = {')))
assert.doesNotMatch(servicePayload, /containerBookingId:|loadingPlanId:/)
assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /customerId:|customerName:/)
assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /链路承运商-/)
@@ -682,7 +687,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' }) const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
const entry = source('../admin-test-env-acceptance.mjs') const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /module === 'phase3-loading'/) assert.match(entry, /module === 'phase3-loading'/)
assert.match(entry, /preparePhase3LoadingFixture\(\)/) assert.match(entry, /preparePhase3LoadingFixture\('', requireSharedOverflow\)/)
assert.match(entry, /orders-phase3-loading-fixture/) assert.match(entry, /orders-phase3-loading-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
assert.match(entry, /marker: originalMarker/) assert.match(entry, /marker: originalMarker/)
@@ -12,15 +12,21 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'),
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } }
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseType: 'OVERSEAS', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
const context = { const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, async request(method, requestPath, body) {
async requestOverseas(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body })
calls.push({ channel: 'overseas', method, path: requestPath, body }) if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
throw new Error(`Unexpected domestic path: ${requestPath}`)
},
async requestOverseas(method, requestPath, body) {
calls.push({ channel: 'overseas', method, path: requestPath, body })
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
@@ -48,8 +54,6 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }]))
if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }])
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
@@ -81,7 +85,10 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
return run(context).then(() => { return run(context).then(() => {
assert.ok(context.calls.length >= 12) assert.ok(context.calls.length >= 12)
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3)
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false)
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
const writes = context.calls.filter((call) => call.method !== 'GET') const writes = context.calls.filter((call) => call.method !== 'GET')
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
@@ -103,10 +110,12 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
test('覆盖冻结的海外只读列表与有记录才查详情', async () => { test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
const context = contextFixture() const context = contextFixture()
await run(context) await run(context)
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/locations?', '/pallets?']) {
assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
} }
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) {
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
} }
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) {
@@ -84,8 +84,6 @@ export async function run(context) {
const payload = { const payload = {
outboundOrderId: null, outboundOrderId: null,
oceanBookingId: ocean.id, oceanBookingId: ocean.id,
containerBookingId: null,
loadingPlanId: null,
warehouseId: id(containerFlow?.warehouseId warehouseId: id(containerFlow?.warehouseId
|| context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), || context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
carrierId: id(carrier.id), carrierName: carrier.name, carrierId: id(carrier.id), carrierName: carrier.name,
@@ -101,7 +99,8 @@ export async function run(context) {
const appointmentId = id(appointment?.id) const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response, context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response,
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
&& !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId) && !Object.hasOwn(appointment || {}, 'containerBookingId')
&& !Object.hasOwn(appointment || {}, 'loadingPlanId')
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return if (!appointmentId) return
@@ -130,7 +129,8 @@ export async function run(context) {
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
&& !id(final?.containerBookingId) && !id(final?.loadingPlanId), && !Object.hasOwn(final || {}, 'containerBookingId')
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
@@ -8,7 +8,7 @@ const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/s
test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{ test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{
for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker))
assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/) assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/)
assert.match(source,/JSON\.stringify\(detail\?\.carrierCodes\)===JSON\.stringify\(\['MATSON','COSCO'\]\)/) assert.match(source,/sameCarrierCodes\(detail\?\.carrierCodes,\['MATSON','COSCO'\]\)/)
assert.doesNotMatch(source,/scope\s*:/) assert.doesNotMatch(source,/scope\s*:/)
}) })
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s))
@@ -96,6 +96,9 @@ test('Admin consumes a server-paged container summary and never derives 15 from
assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /listContainerPlanProgress/)
assert.match(adminApi, /container-summaries/) assert.match(adminApi, /container-summaries/)
assert.match(adminView, /listContainerPlanProgress\(/) assert.match(adminView, /listContainerPlanProgress\(/)
assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/)
assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/)
assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/)
assert.match(adminView, /if \(request !== containerOptionRequest\) return/)
assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/)
}) })
@@ -1,5 +1,6 @@
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments' const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true }
@@ -9,6 +10,9 @@ const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containe
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function responseEvidence(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function data(response) { return codeOf(response) === '200' ? response.body.data : null } 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 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 || [] } function records(value) { return Array.isArray(value) ? value : value?.records || [] }
@@ -201,8 +205,10 @@ export async function run(context) {
const appointmentId = id(appointment?.id); if (!appointmentId) return const appointmentId = id(appointment?.id); if (!appointmentId) return
const path = `${TRUCKS}/${appointmentId}` const path = `${TRUCKS}/${appointmentId}`
context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse,
!hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0, truckAppointmentFieldGaps(appointment).length === 0
`appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS) && !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB'
&& Number(appointment?.payableAmount) === 0,
`appointmentNo=${appointment?.appointmentNo || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(appointment).join('|') || 'none'}`, OPTIONS)
const afterCreateCandidate = await candidate(context, identity) const afterCreateCandidate = await candidate(context, identity)
context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item,
`appointmentNo=${appointment.appointmentNo}`, OPTIONS) `appointmentNo=${appointment.appointmentNo}`, OPTIONS)
@@ -219,12 +225,45 @@ export async function run(context) {
`dispatch-order-${identity.suffix}.pdf`) `dispatch-order-${identity.suffix}.pdf`)
const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
attachmentForm, context.token) attachmentForm, context.token)
const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) const attachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS)
const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) const attachmentListResponse = await request(context, 'GET', `${path}/attachments`)
const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse,
records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'),
`attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) `attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS)
if (!attachment?.id || !attachment?.fileId) return
const deleteUploadedResponse = await request(context, 'DELETE',
`${path}/attachments/${encodeURIComponent(id(attachment?.id))}`)
context.addStep(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse,
deleteUploadedResponse.http === 200 && codeOf(deleteUploadedResponse) === '200',
responseEvidence(deleteUploadedResponse), OPTIONS)
const referencedResponse = await request(context, 'POST', `${path}/attachments`, {
fileId: id(attachment?.fileId), attachmentType: 'DISPATCH_ORDER',
fileName: attachment?.fileName || `dispatch-order-${identity.suffix}.pdf`,
fileHash: attachment?.fileHash || null,
})
const referenced = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referencedResponse, '', OPTIONS)
const referencedListResponse = await request(context, 'GET', `${path}/attachments`)
const referencedList = context.apiSuccess(MODULE, '回查重新引用的拖车附件', referencedListResponse, '', OPTIONS)
context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', referencedListResponse,
records(referencedList).some((item) => id(item?.id) === id(referenced?.id)
&& id(item?.fileId) === id(attachment?.fileId) && item?.attachmentType === 'DISPATCH_ORDER'),
`attachmentId=${id(referenced?.id) || 'missing'}, fileId=${id(attachment?.fileId) || 'missing'}`, OPTIONS)
const deleteReferencedResponse = await request(context, 'DELETE',
`${path}/attachments/${encodeURIComponent(id(referenced?.id))}`)
context.addStep(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse,
deleteReferencedResponse.http === 200 && codeOf(deleteReferencedResponse) === '200',
responseEvidence(deleteReferencedResponse), OPTIONS)
const replacementForm = new FormData(); replacementForm.append('attachmentType', 'DISPATCH_ORDER')
replacementForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER-REPLACEMENT`)],
{ type: 'application/pdf' }), `dispatch-order-replacement-${identity.suffix}.pdf`)
const replacementResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
replacementForm, context.token)
const replacement = context.apiSuccess(MODULE, '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
replacementResponse, '', OPTIONS)
context.assert(MODULE, '恢复附件使用新引用且仍指向平台文件', replacementResponse,
Boolean(id(replacement?.id) && id(replacement?.fileId)) && id(replacement?.id) !== id(attachment?.id),
`attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS)
const editedResponse = await request(context, 'PUT', path, const editedResponse = await request(context, 'PUT', path,
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 })) truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
@@ -254,9 +293,10 @@ export async function run(context) {
const finalDetailResponse = await request(context, 'GET', path) const finalDetailResponse = await request(context, 'GET', path)
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS) const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse, context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail) finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0
&& !hasForbiddenTruckFields(finalDetail)
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId, && id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS) `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS)
const afterCompleteCandidate = await candidate(context, identity) const afterCompleteCandidate = await candidate(context, identity)
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item), context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
+10 -1
View File
@@ -221,6 +221,8 @@ Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPane
# 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。 # 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。
$measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java') $measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java')
$inboundMeasurementService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java')
$measurementSubmissionService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java')
$smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql') $inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql')
$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') $adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
@@ -228,10 +230,17 @@ $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web
Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口'
Assert-Contains $firstlegAgents '实测体积、测量批次冻结申报体积、Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择'
Assert-Contains $firstlegAgents '再无有效值才查询 Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序'
Assert-Contains $firstlegAgents '库位指引失败只记录警告,不阻断称重确认' 'Firstleg 称重库位非阻断语义'
Assert-Contains $firstlegAgents '`ensureForSubmission` 再次补齐和校验' 'Firstleg 提交库位补偿入口'
Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份' Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份'
Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态' Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态'
Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退' Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退'
Assert-Contains $measurementLocationOccupationService 'measuredVolume.max(declaredVolume)' 'Firstleg 测量批次体积取较大值'
Assert-Contains $measurementLocationOccupationService 'ensureForSubmission' 'Firstleg 提交库位补齐实现'
Assert-Contains $inboundMeasurementService 'ensureLocationWithoutBlocking' 'Firstleg 称重库位非阻断实现'
Assert-Contains $measurementSubmissionService 'locations.ensureForSubmission' 'Firstleg 提交库位补齐调用'
Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI' Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI'
Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键'
Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键'