This commit is contained in:
2026-10-22 11:56:47 +08:00
parent 5d71af1ac9
commit de96e47d74
71 changed files with 1296 additions and 332 deletions
+2
View File
@@ -54,6 +54,8 @@
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。 排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
## 唯一入口与区域边界 ## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。 V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
@@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
CREATE TABLE fl_ocean_booking ( CREATE TABLE fl_ocean_booking (
id bigint unsigned NOT NULL COMMENT '海运订舱ID', id bigint unsigned NOT NULL COMMENT '海运订舱ID',
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号', booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键', customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照', customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照', channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
@@ -3465,6 +3465,17 @@ CREATE TABLE fl_ocean_booking (
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间', estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间', estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
freight_term VARCHAR(64) NULL COMMENT '运费条款',
service_term VARCHAR(64) NULL COMMENT '运输条款',
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
@@ -3479,6 +3490,14 @@ CREATE TABLE fl_ocean_booking (
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)), CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
CONSTRAINT chk_ocean_booking_cargo CHECK (
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))), CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED'))) CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -3565,6 +3584,8 @@ CREATE TABLE fl_ocean_booking_party (
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照', address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照', contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照', contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
@@ -6026,9 +6047,49 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
END IF; END IF;
END$$ END$$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
IF OLD.status = 'RELEASED' THEN BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; -- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
AND (NEW.status <=> OLD.status)
AND (NEW.customer_id <=> OLD.customer_id)
AND (NEW.customer_name <=> OLD.customer_name)
AND (NEW.channel_code <=> OLD.channel_code)
AND (NEW.channel_name <=> OLD.channel_name)
AND (NEW.warehouse_id <=> OLD.warehouse_id)
AND (NEW.warehouse_name <=> OLD.warehouse_name)
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
AND (NEW.carrier_code <=> OLD.carrier_code)
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
AND (NEW.vessel_name <=> OLD.vessel_name)
AND (NEW.voyage_no <=> OLD.voyage_no)
AND (NEW.departure_port_code <=> OLD.departure_port_code)
AND (NEW.departure_port_name <=> OLD.departure_port_name)
AND (NEW.destination_port_code <=> OLD.destination_port_code)
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
AND (NEW.package_unit <=> OLD.package_unit)
AND (NEW.cargo_description <=> OLD.cargo_description)
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
AND (NEW.declared_package_count <=> OLD.declared_package_count)
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
AND (NEW.created_by <=> OLD.created_by)
AND (NEW.created_by_name <=> OLD.created_by_name)
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
END IF; END IF;
IF OLD.status <> NEW.status AND NOT ( IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
+1 -1
View File
@@ -79,7 +79,7 @@
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") @BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
@PutMapping("/{oceanBookingId}/actual-cargo")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualCargoRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
}
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')") @DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿") @BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); } public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
@@ -45,10 +45,12 @@ public final class OceanBookingDtos {
String customerName, String warehouseName, String channelName) {} String customerName, String warehouseName, String channelName) {}
public record PartyRequest(@NotBlank @Size(max = 24) String partyType, public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
@NotBlank @Size(max = 255) String partyName, @NotNull @Size(max = 255) String partyName,
@Size(max = 1000) String address, @Size(max = 1000) String address,
@Size(max = 128) String contactName, @Size(max = 128) String contactName,
@Size(max = 64) String contactPhone) {} @Size(max = 64) String contactPhone,
@jakarta.validation.constraints.Email @Size(max = 255) String contactEmail,
@Size(max = 128) String taxNo) {}
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType, public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind, @NotBlank @Size(max = 32) String containerKind,
@@ -76,7 +78,31 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt, LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties, @Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {} @Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements,
@Size(max = 64) String freightTerm,
@Size(max = 64) String serviceTerm,
@Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit,
@Size(max = 1000) String cargoDescription,
@Size(max = 1000) String marksAndNos,
@Min(1) @Max(999999999) Integer declaredPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
/** 放舱后的受控实货量补录,不接收运输资料。 */
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
@NotBlank @Size(max = 1000) String reason) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion, public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {} @Valid @NotNull DraftData data) {}
@@ -40,6 +40,17 @@ public class OceanBookingEntity {
private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt; private LocalDateTime estimatedArrivalAt;
private String remark; private String remark;
private String freightTerm;
private String serviceTerm;
private String packageUnit;
private String cargoDescription;
private String marksAndNos;
private Integer declaredPackageCount;
private java.math.BigDecimal declaredGrossWeightKg;
private java.math.BigDecimal declaredVolumeCbm;
private Integer actualPackageCount;
private java.math.BigDecimal actualGrossWeightKg;
private java.math.BigDecimal actualVolumeCbm;
private Long createdBy; private Long createdBy;
private String createdByName; private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -17,4 +17,6 @@ public class OceanBookingPartyEntity {
private String address; private String address;
private String contactName; private String contactName;
private String contactPhone; private String contactPhone;
private String contactEmail;
private String taxNo;
} }
@@ -25,8 +25,17 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
""") """)
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion, int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
@Update("""
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
""")
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int deleteDraft(@Param("id") long id, @Param("version") int version); int deleteDraft(@Param("id") long id, @Param("version") int version);
@Select(""" @Select("""
@@ -54,6 +54,10 @@ public class OceanBookingRepository {
public int touchDraft(long id, int version, long operatorId, String operatorName) { public int touchDraft(long id, int version, long operatorId, String operatorName) {
return bookings.touchDraft(id, version, operatorId, operatorName); return bookings.touchDraft(id, version, operatorId, operatorName);
} }
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateActualCargo(value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) { public int deleteDraft(long id, int version) {
return bookings.deleteDraft(id, version); return bookings.deleteDraft(id, version);
} }
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf"; String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest( PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument( template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(emptyCargo())))); fields(template, detail), List.of(cargo(template, booking)))));
try { try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request); ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType(); MediaType contentType = response.getHeaders().getContentType();
@@ -75,14 +75,21 @@ public class OceanBookingPrintService {
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode())); fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode())); fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", ""); fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", ""); fields.put("freightTerm", text(booking.getFreightTerm()));
fields.put("totalPackagesInWords", ""); Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
if (SHIPPING_INSTRUCTION.equals(template)) { if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName())); fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo())); fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt())); fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", ""); fields.put("cargoQuantity", detail.containerRequirements().stream()
.map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + ")));
OceanBookingPartyEntity shipper = parties.get("SHIPPER");
fields.put("picName", shipper == null ? "" : text(shipper.getContactName()));
fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone()));
fields.put("apName", text(booking.getBookingAgentName()));
fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString());
fields.put("carrier", text(booking.getShippingCompanyName())); fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName())); fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark())); fields.put("remark", text(booking.getRemark()));
@@ -98,8 +105,43 @@ public class OceanBookingPrintService {
return fields; return fields;
} }
private static PrintPdfClient.CargoRow emptyCargo() { /** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—"); private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
boolean declared = SHIPPING_INSTRUCTION.equals(template);
Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg();
BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm();
return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(),
display(booking.getPackageUnit()), display(booking.getCargoDescription()),
weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS",
volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM");
}
/** 打印空字段使用统一占位符。 */
private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); }
/** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */
private static String packageWords(Integer count, String unit) {
if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return "";
String packaging = count == 1 ? switch (unit) {
case "CARTONS" -> "CARTON";
case "PALLETS" -> "PALLET";
case "PCS" -> "PIECE";
default -> unit;
} : ("PCS".equals(unit) ? "PIECES" : unit);
return "SAY " + words(count) + " " + packaging + " ONLY";
}
/** 将受件数范围约束的正整数转换为英文数量。 */
private static String words(int value) {
String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN",
"ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"};
String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"};
if (value < 20) return small[value];
if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10));
int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100;
String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED";
return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor));
} }
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) { private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
@@ -110,7 +152,8 @@ public class OceanBookingPrintService {
private static String party(OceanBookingPartyEntity value) { private static String party(OceanBookingPartyEntity value) {
if (value == null) return ""; if (value == null) return "";
return List.of(text(value.getPartyName()), text(value.getAddress()), return List.of(text(value.getPartyName()), text(value.getAddress()),
join(value.getContactName(), value.getContactPhone())).stream() join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n")); .filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
} }
@@ -145,6 +145,26 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName); addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after)); return complete(id, operatorId, action, key, detail(after));
} }
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
@Transactional
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
String action = "UPDATE_ACTUAL_CARGO";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.RELEASED);
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
after.setActualPackageCount(request.actualPackageCount());
after.setActualGrossWeightKg(request.actualGrossWeightKg());
after.setActualVolumeCbm(request.actualVolumeCbm());
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity saved = required(id);
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
return complete(id, operatorId, action, key, detail(saved));
}
@Transactional @Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) { public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT"; String action = "DELETE_DRAFT";
@@ -209,7 +229,18 @@ public class OceanBookingService {
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; } private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
/** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */
private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数");
for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) {
if (number != null && (number.signum() <= 0 || number.scale() > 3
|| number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数");
}
}
private void validateDraftStructure(DraftData data) { private void validateDraftStructure(DraftData data) {
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
boolean hasCustomerId = data.customerId() != null; boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName()); boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交"); if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
@@ -242,6 +273,7 @@ public class OceanBookingService {
|| !StringUtils.hasText(booking.getPlaceOfDelivery()) || !StringUtils.hasText(booking.getPlaceOfDelivery())
|| !StringUtils.hasText(booking.getCarrierCode()) || !StringUtils.hasText(booking.getCarrierCode())
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
} }
@@ -298,6 +330,17 @@ public class OceanBookingService {
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
v.setRemark(trim(d.remark())); v.setRemark(trim(d.remark()));
v.setFreightTerm(trim(d.freightTerm()));
v.setServiceTerm(trim(d.serviceTerm()));
v.setPackageUnit(trim(d.packageUnit()));
v.setCargoDescription(trim(d.cargoDescription()));
v.setMarksAndNos(trim(d.marksAndNos()));
v.setDeclaredPackageCount(d.declaredPackageCount());
v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg());
v.setDeclaredVolumeCbm(d.declaredVolumeCbm());
v.setActualPackageCount(d.actualPackageCount());
v.setActualGrossWeightKg(d.actualGrossWeightKg());
v.setActualVolumeCbm(d.actualVolumeCbm());
} }
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) { private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
@@ -370,7 +413,7 @@ public class OceanBookingService {
return normalized; return normalized;
} }
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); } private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try { try {
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照', channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
@@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
remark VARCHAR(1000) NULL COMMENT '平台内部备注', remark VARCHAR(1000) NULL COMMENT '平台内部备注',
freight_term VARCHAR(64) NULL COMMENT '运费条款',
service_term VARCHAR(64) NULL COMMENT '运输条款',
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
@@ -44,6 +55,14 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
('DRAFT','RELEASED')), ('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_carrier_code CHECK CONSTRAINT chk_ocean_booking_carrier_code CHECK
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_ocean_booking_cargo CHECK (
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
CONSTRAINT chk_ocean_booking_schedule CHECK CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -110,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
address VARCHAR(1000) NULL COMMENT '关系方地址快照', address VARCHAR(1000) NULL COMMENT '关系方地址快照',
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照', contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照', contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
@@ -211,8 +232,47 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$ DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN BEGIN
IF OLD.status = 'RELEASED' THEN -- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
AND (NEW.status <=> OLD.status)
AND (NEW.customer_id <=> OLD.customer_id)
AND (NEW.customer_name <=> OLD.customer_name)
AND (NEW.channel_code <=> OLD.channel_code)
AND (NEW.channel_name <=> OLD.channel_name)
AND (NEW.warehouse_id <=> OLD.warehouse_id)
AND (NEW.warehouse_name <=> OLD.warehouse_name)
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
AND (NEW.carrier_code <=> OLD.carrier_code)
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
AND (NEW.vessel_name <=> OLD.vessel_name)
AND (NEW.voyage_no <=> OLD.voyage_no)
AND (NEW.departure_port_code <=> OLD.departure_port_code)
AND (NEW.departure_port_name <=> OLD.departure_port_name)
AND (NEW.destination_port_code <=> OLD.destination_port_code)
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
AND (NEW.package_unit <=> OLD.package_unit)
AND (NEW.cargo_description <=> OLD.cargo_description)
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
AND (NEW.declared_package_count <=> OLD.declared_package_count)
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
AND (NEW.created_by <=> OLD.created_by)
AND (NEW.created_by_name <=> OLD.created_by_name)
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
END IF; END IF;
IF OLD.status <> NEW.status AND NOT ( IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Ocean Booking API title: Ocean Booking API
version: 2.1.0 version: 2.2.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only. description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
paths: paths:
/api/platform/v1/ocean-bookings: /api/platform/v1/ocean-bookings:
get: get:
@@ -29,6 +29,14 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}} responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
put:
summary: Supplement manual actual cargo on a RELEASED booking
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release: /api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post: post:
summary: Validate and permanently release a DRAFT summary: Validate and permanently release a DRAFT
@@ -89,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get: get:
summary: Render the current saved booking as an export shipping instruction PDF summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions. description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit.
x-permission: platform:firstleg:ocean-booking:list x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses: responses:
@@ -97,7 +105,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get: get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts. description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment.
x-permission: platform:firstleg:ocean-booking:list x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses: responses:
@@ -194,10 +202,31 @@ components:
estimatedSailingAt: {type: string, format: date-time, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true} estimatedArrivalAt: {type: string, format: date-time, nullable: true}
remark: {type: string, nullable: true} remark: {type: string, nullable: true}
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true}
cargoDescription: {type: string, maxLength: 1000, nullable: true}
marksAndNos: {type: string, maxLength: 1000, nullable: true}
declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}} PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}} ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
ActualCargoRequest:
type: object
additionalProperties: false
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 1000}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]} DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
@@ -311,6 +340,8 @@ components:
required: [oceanBooking, parties, containerRequirements, containers, documents, histories] required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
properties: properties:
oceanBooking: oceanBooking:
allOf:
- {$ref: '#/components/schemas/DraftData'}
type: object type: object
properties: properties:
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号} bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
@@ -32,6 +32,7 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"), Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"), Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("create", "platform:firstleg:ocean-booking:create"), Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"), Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release")); Map.entry("release", "platform:firstleg:ocean-booking:release"));
@@ -47,6 +48,24 @@ class PlatformOceanBookingControllerContractTest {
}); });
} }
@Test
@SuppressWarnings("unchecked")
void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception {
assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value())
.containsExactly("/{oceanBookingId}/actual-cargo");
Map<String, Object> document = new org.yaml.snakeyaml.Yaml().load(
Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")));
var paths = (Map<String, Object>) document.get("paths");
var endpoint = (Map<String, Object>) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo");
var operation = (Map<String, Object>) endpoint.get("put");
assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit");
var schemas = (Map<String, Object>) ((Map<String, Object>) document.get("components")).get("schemas");
var request = (Map<String, Object>) schemas.get("ActualCargoRequest");
assertThat((Map<String, Object>) request.get("properties")).containsOnlyKeys(
"expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm");
assertThat((java.util.List<String>) request.get("required")).contains("expectedVersion", "reason");
}
@Test @Test
void pathVariablesUseExplicitAggregateNames() { void pathVariablesUseExplicitAggregateNames() {
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
@@ -46,6 +46,16 @@ class OceanBookingMapperContractTest {
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
} }
@Test
void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo",
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1",
"actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}")
.doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit=");
}
@Test @Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
@@ -27,8 +27,8 @@ class OceanBookingSqlContractTest {
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)") .contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)") .contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.doesNotContain("service_term") .contains("service_term", "declared_package_count", "actual_package_count")
.doesNotContain("freight_term") .contains("freight_term", "contact_email", "tax_no")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
@@ -49,7 +49,7 @@ class OceanBookingSqlContractTest {
void enforcesStateIdempotencyAndAuditContracts() throws Exception { void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql()) assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'") .contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'") .contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)") .contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL") .contains("request_hash CHAR(64) NOT NULL")
.contains("<> 'RELEASED' THEN") .contains("<> 'RELEASED' THEN")
@@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest {
assertThat(document.fields()).containsEntry("jobNumber", "OB-1") assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress") .containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001") .containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsEntry("cargoQuantity", "—") .containsEntry("cargoQuantity", "2*40HQ")
.containsEntry("totalPackagesInWords", "—") .containsEntry("totalPackagesInWords", "—")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity", .containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords"); "portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
@@ -95,6 +95,39 @@ class OceanBookingPrintServiceTest {
.containsEntry("totalPackagesInWords", "—"); .containsEntry("totalPackagesInWords", "—");
} }
@Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() {
Detail detail = detail("RELEASED");
var booking = detail.oceanBooking();
booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480);
booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68"));
booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321"));
booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M");
booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID");
detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX");
when(bookings.detail(10L)).thenReturn(detail);
when(print.generateFreightA4Documents(any(), any(), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1}));
service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split");
var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture());
var declared = capture.getAllValues().get(0).documents().get(0);
var actual = capture.getAllValues().get(1).documents().get(0);
assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500");
assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS");
assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY")
.containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID");
assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX");
assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480");
assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS");
assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM");
assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY");
booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null);
service.billConfirmation(10L, "missing");
verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture());
assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—");
assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—");
}
private static Detail detail(String status) { private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status); booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
@@ -386,6 +386,91 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
} }
@Test
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
OceanBookingEntity before = completeBooking("RELEASED", 3);
before.setDeclaredPackageCount(500);
before.setActualPackageCount(450);
AtomicReference<OceanBookingEntity> stored = new AtomicReference<>(before);
when(repository.find(1L)).thenAnswer(invocation -> stored.get());
when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> {
OceanBookingEntity after = invocation.getArgument(0);
after.setVersion(4); stored.set(after); return 1;
});
var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量");
var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request);
assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480);
assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500);
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(before.getActualPackageCount()).isEqualTo(450);
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO");
assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450");
assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480");
assertThat(history.getValue().getReason()).isEqualTo("修正实装货量");
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
verify(repository, never()).replaceRequirements(anyLong(), any());
verifyNoInteractions(documents, priceBooks, suppliers, warehouses);
}
@Test
void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() {
OceanBookingEntity value = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(value);
var request = new ActualCargoRequest(3, 100, null, null, "补录");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class);
value.setStatus("RELEASED");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale",
new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason",
new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative",
new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision",
new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class);
when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class);
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
}
@Test
void replayedActualCargoDoesNotWriteAgain() throws Exception {
var request = new ActualCargoRequest(3, 100, null, null, "补录");
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
var operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request));
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation);
assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4);
verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any());
}
@Test
void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception {
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null));
tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "")
.put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX");
stubCreatedDraft();
service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class)));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity>> capture = ArgumentCaptor.forClass(List.class);
verify(repository).replaceParties(anyLong(), capture.capture());
assertThat(capture.getValue()).singleElement().satisfies(party -> {
assertThat(party.getContactEmail()).isEqualTo("ops@example.test");
assertThat(party.getTaxNo()).isEqualTo("TEST-TAX");
});
var draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
var parties = parties(); parties.get(0).setPartyName("");
when(repository.parties(1L)).thenReturn(parties);
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any());
}
private AtomicReference<OceanBookingEntity> stubCreatedDraft() { private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>(); AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
@@ -412,7 +497,7 @@ class OceanBookingServiceTest {
private static DraftData partialDraft(String remark) { private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null, null, return new DraftData(null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, remark, null, null); null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null);
} }
private static DraftData completeDraft() { private static DraftData completeDraft() {
@@ -420,11 +505,11 @@ class OceanBookingServiceTest {
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", LocalDateTime.of(2026, 9, 1, 0, 0), "LAX", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), List.of(new PartyRequest("SHIPPER", "发货人", null, null, null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)),
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2, List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
new BigDecimal("68"), new BigDecimal("19500")))); new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null);
} }
private static OceanBookingEntity completeBooking(String status, int version) { private static OceanBookingEntity completeBooking(String status, int version) {
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
`procurement-sample-test-env.mjs` 复用固定客户后只读取钱包,不再创建充值流水;执行前要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不足或余额字段异常即停止。仍会创建本轮采购业务与附件,不复用历史完成单据。
`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 `dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。
Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。
+4
View File
@@ -642,6 +642,9 @@ export class AcceptanceRun {
this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || ''
this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || '' this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || ''
this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || '' this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || ''
this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]')
this.options.fixedDomesticDenyActor ??= JSON.parse(process.env.ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null')
this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_TEST_FLEET_RESOURCES || '[]')
} }
const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12)
this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}`
@@ -665,6 +668,7 @@ export class AcceptanceRun {
if (this.options.environment !== 'test') return if (this.options.environment !== 'test') return
const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '') const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '')
if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants' if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants'
|| pathname === '/api/auth/register'
|| /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) { || /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) {
throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户') throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户')
} }
+16 -4
View File
@@ -1,5 +1,17 @@
# Admin 测试环境 HTTP 模块 # Admin 测试环境 HTTP 模块
固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。
国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。
固定 PDA 账号取得会话后即占用;权限核验失败也检查退出结果。退出被拒绝或请求异常会记录失败并保留占用,禁止本轮再次分配该账号,同时继续尝试退出其他账号。不将退出未知结果视为清理成功。
固定海外 PDA 账号池通过显式环境文件的 `ADMIN_TEST_OVERSEAS_PDA_ACTORS` JSON 数组配置,每项为 `membershipId`、`warehouseId`、`username`、`password`,不得将实际值写入仓库或报告。同仓双操作员场景需要两个不同账号;当前 runner 按 membershipId 占用并在退出会话成功后释放。使用前核对 ACTIVE、平台身份、PDA 角色及仓绑定;缺失或不匹配直接阻断,不创建或修复账号。复用账号只注销本轮会话,不停用、不改绑、不清空角色;仓绑定撤销生命周期明确列为未验收。该占用只在当前 runner 内有效,仍须遵守共享环境造数串行规则。
固定客户下,报关、装柜、订舱级拖车和车队报价复用启用供应商;按 CUSTOMS/BOOKING/TRUCKING 类别、MATSON 船司及国内/海外拖车范围核验列表和详情。缺少匹配供应商时阻断,不自动创建或重新启用;车队报价结束只停用本轮报价,不停用复用供应商。
固定客户模式下,PDA 换标只核验现有可用余额至少 0.01 元(本场景换标费用),不自动补款;采购样本专项要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不自动充值。海外采购合作仓从正式候选复用,退出时不得停用复用仓。固定客户及本轮入仓计划查询覆盖后续分页;混托专项缺第二固定客户时,在首次下单和测量前阻断。以上前置检查不替代服务端执行时的实时余额、资格与版本校验。
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。 固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。 固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
@@ -78,7 +90,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。 `fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;通过正式 API 准备资源与两级审核,读取唯一自动生成的 Fleet 记录。PDA 拣货及来源核验通过后,读取本车五个装车照片框,复用已保存文件,仅为缺失框上传标明 TEST 的合成 PNG,再绑定、原键重放并回读;上传前保存检查点与文件摘要,未知结果停写,不自动重新上传。照片在 LOADING 保存不得提前发车;AFTER_LOADING 最后一个缺失框保存后须进入 DELIVERING。资料齐全后重新读取主单资格与实载清单,再确认装车并核对自动生成的派送,不再调用手工安排派送或发车。实载数据由服务端返回,重量和体积缺失保持未知,不以请求中的测量值或计费量替代。PDA 上传及绑定始终保留原账号会话;ID 和版本无损保留,资源及文件留在报告。直接调用者须消费当前 DTO;离线合同通过不代表真实 HTTP、实物照片或 PDA 硬件验收。
`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。 `prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。
@@ -257,7 +269,7 @@ export async function run(context) {
| 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 | | 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 |
| 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 | | 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 |
| 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 | | 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 |
| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 | | 排车编辑、BOL、自动派送 | `phase5-overseas-fulfillment` | 本轮主链排车 | 核对车辆编辑、BOL 文件、PDA 拣货、五框资料及装车自动生成出库/派送;双端回读阶段版本、客户归属和数量,库存与 SHIP 零重复扣减;继续逐单签收、异常和回访 |
| 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 | | 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 |
| PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture | | PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture |
@@ -320,7 +332,7 @@ export async function run(context) {
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | | 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
`phase4-overseas-inbound-operations.mjs` 的内部 `prepareAppointmentFixture` 可由已核对的本轮最小正式 API 流程复用:必须传入真实转换所得的入仓计划和同仓明细,至少两箱才执行拆分合并;两箱验证 `2 → 1 + 1 → 2`,不补造货量。完整 Admin 模块仍按上表四箱参数及统一依赖运行,不能为缩减造数而伪造生产者矩阵或跳过前置事实。 `phase4-overseas-inbound-operations.mjs` 的内部 `prepareAppointmentFixture` 可由已核对的本轮最小正式 API 流程复用:必须传入真实转换所得的入仓计划和同仓明细,至少两箱才执行拆分合并;两箱验证 `2 → 1 + 1 → 2`,不补造货量。完整 Admin 模块仍按上表四箱参数及统一依赖运行,不能为缩减造数而伪造生产者矩阵或跳过前置事实。
@@ -347,7 +359,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway <test-gateway> --modules ph
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`,只消费本轮国内装柜、海外接柜与 PDA 实收形成的排车和库存。正式资源及两级审核就绪后,由绑定同仓的 PDA 逐来源拣货,补齐五框装车资料,再确认装车;服务端自动生成客户出库与派送并发车。五期、F40、运力池和保留样本专项统一使用 `readAutomaticDispatchDelivery`、`verifyAutomaticDeparture` 只读核对单据、双端阶段/版本及客户数量,禁止重复调用手工转派送或发车。装车重放由共享助手核验,之后独立回查库存和 SHIP 不重复扣减,再逐单签收并验证头程回推。固定账号只退出本轮会话;非固定专项账号按原生命周期清理。前置不足明确失败,不能借历史单据冒充本轮自动发车。
该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。 该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。
@@ -3,6 +3,30 @@ import assert from 'node:assert/strict'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { verifyPermissionDenial } from './domestic-container.mjs'
test('fixed denial actor never creates, resets or disables accounts and always logs out', async () => {
for (const deniedCode of ['A0403', '200']) {
const calls = []
const actor = { membershipId: '123', username: 'deny-fixture', password: 'fixture-password' }
const context = { options: { fixedCustomerId: '456', fixedDomesticDenyActor: actor },
async request(method, path) {
calls.push({ method, path })
const data = path.includes('/iam/users?') ? { total: 1, records: [{ ...actor, tenantId: '0', status: 'ACTIVE' }] }
: path === '/api/auth/login' ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } : {}
return { http: 200, body: { code: path.includes('/inventories?') ? deniedCode : '200', data }, requestId: 'test', traceId: 'test' }
}, apiSuccess(_module, _label, response) { return response.body.data }, assert() {},
}
if (deniedCode === 'A0403') await verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER')
else await assert.rejects(verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER'), /实际权限不符/)
assert.equal(calls.at(-1).path, '/api/auth/logout')
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
}
})
test('missing fixed denial account blocks without requesting a phone or making HTTP calls', async () => {
await assert.rejects(verifyPermissionDenial({ options: { fixedCustomerId: '123' } }, 'RUN', '789', 'CONTAINER'), /禁止自动创建/)
})
const here = path.dirname(fileURLToPath(import.meta.url)) const here = path.dirname(fileURLToPath(import.meta.url))
const moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8') const moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8')
@@ -131,6 +131,7 @@ export async function run(context) {
if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续') if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续')
const warehouseId = id(warehouse.id) const warehouseId = id(warehouse.id)
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase() const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
if (context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo)
const now = new Date().toISOString().slice(0, 19) const now = new Date().toISOString().slice(0, 19)
await verifyValidationBoundaries(context, warehouseId, marker, now) await verifyValidationBoundaries(context, warehouseId, marker, now)
@@ -222,11 +223,38 @@ export async function run(context) {
await verifyExtendedInventoryScenarios(context, warehouseId, marker, now) await verifyExtendedInventoryScenarios(context, warehouseId, marker, now)
await verifyPermissionDenial(context, marker, warehouseId, containerNo) if (!context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo)
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS } context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
} }
async function verifyPermissionDenial(context, marker, warehouseId, containerNo) { export async function verifyPermissionDenial(context, marker, warehouseId, containerNo) {
if (context.options?.fixedCustomerId) {
const actor = context.options.fixedDomesticDenyActor
if (!actor?.username || !actor.password || !/^[1-9]\d*$/.test(id(actor.membershipId))) {
throw Error('缺少固定整柜权限拒绝账号,禁止自动创建用户或角色')
}
let user
for (let page = 1; ; page++) {
const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
const result = context.apiSuccess(MODULE, '查询固定权限拒绝账号', response)
user = result?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
if (user || !result?.records?.length || page * Number(result.size || 100) >= Number(result.total || 0)) break
}
if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0') throw Error('固定权限拒绝账号必须为启用的平台账号')
const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '')
const session = context.apiSuccess(MODULE, '登录固定权限拒绝账号', login)
if (!session?.accessToken || !session.refreshToken) throw Error('固定权限拒绝账号未返回完整会话')
try {
const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, session.accessToken)
expectCode(context, '固定无整柜权限用户被拒绝', denied, 'A0403')
if (denied.http !== 200 || String(denied.body?.code) !== 'A0403') throw Error('固定探针账号实际权限不符,停止整柜造数')
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken)
context.apiSuccess(MODULE, '退出固定权限拒绝账号会话,不停用账号或角色', logout)
if (!succeeded(logout)) throw Error('固定权限拒绝账号退出失败')
}
return
}
const phone = await context.findAvailablePhone(MODULE) const phone = await context.findAvailablePhone(MODULE)
const roleCode = `DC_DENY_${marker}`.slice(0, 64) const roleCode = `DC_DENY_${marker}`.slice(0, 64)
const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase() const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase()
@@ -1,6 +1,11 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { run } from './dropship-mixed-palletization.mjs'
test('single fixed customer blocks before first order or measurement', async () => {
await assert.rejects(run({ options: { fixedCustomerId: '123' } }), /停止下单和测量造数/)
})
const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
@@ -8,6 +8,7 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。 // 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
export async function run(context) { export async function run(context) {
if (context.options?.fixedCustomerId) throw new Error('混托需要第二个固定客户,停止下单和测量造数')
const originalMarker = context.marker const originalMarker = context.marker
const savedContext = { ...context.context } const savedContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount const originalManualCount = context.options.prepareDropshipManualCount
@@ -14,8 +14,8 @@ test('fixed customer supplier selection paginates and never creates a fallback',
async request(method, path) { async request(method, path) {
calls.push({ method, path }) calls.push({ method, path })
const data = path.includes('/123?') ? supplier : path.includes('page=2') const data = path.includes('/123?') ? supplier : path.includes('page=2')
? { records: [supplier], total: 101 } ? { records: [supplier], size: 20, total: 21 }
: { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 } : { records: Array.from({ length: 20 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), size: 20, total: 21 }
return { body: { data } } return { body: { data } }
}, },
apiSuccess(_module, _name, response) { return response.body.data }, apiSuccess(_module, _name, response) { return response.body.data },
@@ -30,6 +30,22 @@ test('fixed customer supplier selection paginates and never creates a fallback',
}) })
const here = path.dirname(fileURLToPath(import.meta.url)) const here = path.dirname(fileURLToPath(import.meta.url))
test('reused suppliers must match carrier and trucking scope in both list and detail', async () => {
for (const criteria of [{ carrierCode: 'MATSON' }, { truckingScope: 'OVERSEAS' }]) {
const good = { id: '123', category: 'BOOKING', status: 'ACTIVE', carrierCodes: ['MATSON'], truckingScope: 'OVERSEAS' }
let detail = good
const context = { options: { fixedCustomerId: '456' }, marker: 'REUSE',
async request(method, path) {
assert.equal(method, 'GET')
return { body: { data: path.includes('/123?') ? detail : { records: [
{ ...good, id: '999', carrierCodes: [], truckingScope: 'DOMESTIC' }, good], total: 2 } } }
}, apiSuccess(_module, _name, response) { return response.body.data }, addStep() {},
}
assert.equal((await loadSupplier(context, 'BOOKING', criteria)).id, '123')
detail = { ...good, carrierCodes: [], truckingScope: 'DOMESTIC' }
assert.equal(await loadSupplier(context, 'BOOKING', criteria), null)
}
})
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
test('finance fixture module is independently exported and marker isolated', () => { test('finance fixture module is independently exported and marker isolated', () => {
@@ -258,23 +258,25 @@ async function loadPublishedAutomaticFee(context, resource) {
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
} }
export async function loadSupplier(context, category) { export async function loadSupplier(context, category, { carrierCode, truckingScope } = {}) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
if (context.options?.fixedCustomerId) { if (context.options?.fixedCustomerId) {
const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)
&& (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope)
for (let page = 1; ; page += 1) { for (let page = 1; ; page += 1) {
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`) const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS) const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
if (!data) return null if (!data) return null
const records = rows(data) const records = rows(data)
const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)) const candidate = records.find(matches)
if (candidate) { if (candidate) {
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`) const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS) const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商') blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
return null return null
} }
if (!records.length || page * 100 >= Number(data.total ?? records.length)) break if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break
} }
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`) blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
return null return null
@@ -1,3 +1,4 @@
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
@@ -49,6 +50,7 @@ function model(fault = '') {
} }
return response({...prior.result,replayed:true}) return response({...prior.result,replayed:true})
} }
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()})
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') { if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') {
@@ -1,3 +1,4 @@
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
@@ -86,6 +87,7 @@ function fixture(fault = '', nativeMode = false) {
if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) })
if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo })
if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops })
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1,
evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) })
if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) }
@@ -95,7 +97,7 @@ function fixture(fault = '', nativeMode = false) {
if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) }
if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) }
if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) }
if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: items.map((item, index) => ({ warehousePalletId: item.warehousePalletId, palletSpaces: 1, weightKg: index === 2 ? 37 : 10, volumeCbm: index === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || index === 2 ? 1 : index + 1 })) } })) }
throw Error(`Unexpected ${method} ${raw}`) throw Error(`Unexpected ${method} ${raw}`)
} } } }
const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'} const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'}
@@ -6,6 +6,21 @@ import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureC
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const copy = value => structuredClone(value) const copy = value => structuredClone(value)
test('fixed quote supplier is reused without creation or deactivation', async () => {
const { context } = harness(), request = context.request.bind(context), writes = []
context.options = { fixedCustomerId: '101' }
const supplier = { id: '901', name: 'Existing', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }
context.request = async (method, path, ...args) => {
if (path.includes('/suppliers')) {
if (method !== 'GET') writes.push({ method, path })
return { http: 200, body: { code: '200', data: path.includes('/901') ? supplier : { records: [supplier], total: 1 } } }
}
return request(method, path, ...args)
}
await run(context)
assert.equal(context.createdResources.fleetQuoteAcceptance.supplierReused, true)
assert.deepEqual(writes, [])
})
function harness(fault) { function harness(fault) {
const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z'
const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = [] const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = []
@@ -227,10 +227,14 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
} }
if (scope === 'QUOTES_ONLY') { if (scope === 'QUOTES_ONLY') {
const customer = requireQuoteCustomer(context) const customer = requireQuoteCustomer(context)
const supplier = await command('POST', '/api/platform/v1/suppliers?tenantId=0', { const supplier = context.options?.fixedCustomerId
? await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' })
: await command('POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker, name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker,
region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30', region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30',
}, '新建本轮海外车队供应商') }, '新建本轮海外车队供应商')
if (!supplier) throw new Error('缺少现有海外拖车供应商,不自动创建')
proof.supplierReused = Boolean(context.options?.fixedCustomerId)
proof.supplierId = id(supplier.id ?? supplier) proof.supplierId = id(supplier.id ?? supplier)
check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId)) check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId))
const day = new Date().toISOString().slice(0, 10) const day = new Date().toISOString().slice(0, 10)
@@ -311,7 +315,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史') if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史')
} catch (error) { cleanupErrors.push(error.message) } } catch (error) { cleanupErrors.push(error.message) }
} }
if (proof.supplierId && !context.quoteRequestOutcomeUnknown) { if (proof.supplierId && !proof.supplierReused && !context.quoteRequestOutcomeUnknown) {
try { try {
const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}` const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}`
const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' }) const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' })
@@ -1,3 +1,4 @@
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
@@ -33,7 +34,7 @@ function fixture(fault = '') {
const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage,
documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] }, documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] },
reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked,
vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) vehicleInfo: { vehicle: { vehicleId: '601', plate: vehiclePlate }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] })
const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 })
const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 }) const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 })
const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false }) const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false })
@@ -113,8 +114,9 @@ function fixture(fault = '') {
if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] })
if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops })
if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] })
if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2,
...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] })
if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] })
if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next)) if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next))
assert.ok(created, `${method} ${raw}`) assert.ok(created, `${method} ${raw}`)
@@ -124,7 +126,7 @@ function fixture(fault = '') {
assert.equal(body.recordVersion, recordVersion, raw) assert.equal(body.recordVersion, recordVersion, raw)
if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) }
if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) }
if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: '100', palletSpaces: 1, weightKg: fault === 'missing-measurements' ? null : 37, volumeCbm: fault === 'missing-measurements' ? null : 0.7, deliveryIndex: 1 }] } })) }
throw Error(`Unexpected ${method} ${raw}`) throw Error(`Unexpected ${method} ${raw}`)
} } } }
const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } }
@@ -133,6 +135,15 @@ function fixture(fault = '') {
} }
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
test('server-owned loading permits absent optional weight and volume without inventing measurements', async () => {
const f = fixture('missing-measurements')
await run(f)
const request = f.requests.find(item => item.path.endsWith('/loading-complete'))
assert.ok(request)
assert.equal(request.body?.measurements, undefined)
assert.equal(f.trip.returnRedispatch.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
})
test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => { test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => {
const f = fixture(), result = await run(f) const f = fixture(), result = await run(f)
assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3)
@@ -172,10 +183,9 @@ for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'fore
for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => { for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => {
const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET')) const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET'))
}) })
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => { for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => {
const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED')
if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET')) if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET'))
if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false)
}) })
test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => { test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => {
const f = fixture('shared-root'), proof = await run(f) const f = fixture('shared-root'), proof = await run(f)
@@ -61,7 +61,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') }
proof.requests.push(entry); await context.checkpoint() proof.requests.push(entry); await context.checkpoint()
try { try {
const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) const response = file ? await context.requestMultipart(method, path, file, token) : await context.request(method, path, body, token, options)
if (!definiteResponse(response)) { if (!definiteResponse(response)) {
stopped = true; proof.outcomeUnknown = true stopped = true; proof.outcomeUnknown = true
Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code,
@@ -77,7 +77,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
} catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error } } catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error }
} }
io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options)
io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) io.requestMultipart = (method, path, file, token) => invoke(method, path, undefined, token, file)
async function request(method, path, body, token) { async function request(method, path, body, token) {
const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data
} }
@@ -1,3 +1,4 @@
import { LOADING_PHOTO_SLOTS, ensureFleetLoadingDocuments, reuseFleetResources } from './fleet-trip-fulfillment.mjs'
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
@@ -17,6 +18,69 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo:
{ warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] }, { warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] },
] }) ] })
for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => {
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map()
const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage,
complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] }
let saved = false
const context = { async checkpoint() { saved = true },
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response.body.data },
async requestMultipart(method, path, file, token) {
assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST')
const slot = path.split('/').at(-2)
assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png')
assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot)
assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED')
return ok({ slot, fileId: `new-${slot}` })
},
async request(method, path, body, token) {
assert.equal(token, 'PDA-TEST')
if (method === 'GET') return ok(state)
assert.equal(method, 'PUT')
if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey))
assert.equal(body.recordVersion, state.recordVersion)
state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId })
state.recordVersion = String(BigInt(state.recordVersion) + 1n)
state.complete = state.photos.length === 5
if (state.complete && initialStage === 'AFTER_LOADING') state.stage = 'DELIVERING'
replays.set(body.idempotencyKey, structuredClone(state)); return ok(state)
} }
await ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST')
assert.deepEqual(calls, LOADING_PHOTO_SLOTS.slice(1))
assert.equal(state.photos[0].fileId, 'existing')
assert.equal(state.stage, initialStage === 'LOADING' ? 'LOADING' : 'DELIVERING')
assert.equal(proof.loadingDocuments.result.complete, true)
})
for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => {
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = []
const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] }
const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') },
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data },
async requestMultipart(_method, path) {
calls.push(path)
if (fault === 'upload-unknown') throw Error('unknown upload')
return ok({ slot: fault === 'wrong-slot' ? 'RECEIPT' : 'LOADING_SCENE', fileId: 'new-file' })
},
async request(method, path) {
calls.push(path)
if (method === 'GET') return ok(state)
if (state.photos.length && fault === 'replay-drift') return ok({ ...state, recordVersion: '3' })
state.photos = [{ slot: 'LOADING_SCENE', fileId: 'new-file' }]
if (fault === 'early-departure') state.stage = 'DELIVERING'
return ok(state)
} }
await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST'))
assert.equal(calls.some(path => path.endsWith('/loading-complete')), false)
if (fault === 'checkpoint') assert.equal(calls.length, 1)
if (fault === 'upload-unknown') {
assert.equal(proof.loadingDocuments.uploads[0].status, 'UNKNOWN')
const sent = calls.filter(path => path.includes('/uploads?')).length
await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST'), /不自动重发/)
assert.equal(calls.filter(path => path.includes('/uploads?')).length, sent)
}
})
test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => { test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => {
const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets) const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets)
for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) { for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) {
@@ -37,7 +101,7 @@ function server(fault = '') {
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false, counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false,
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail let driverVersion = 0, uploadComplete = false, driverReviewed = fault === 'fixed-master', nextPlate = fault === 'fixed-master' ? 'TEST-EXISTING' : '', checkpointError = false, nextTrip, nextDetail
let mutatedItems = false, mutatedLoading = false let mutatedItems = false, mutatedLoading = false
const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']]) const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']])
const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n) const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n)
@@ -57,6 +121,9 @@ function server(fault = '') {
}, },
async request(method, path, body, token) { async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' })
if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown')
if (path.endsWith('/master-data/vehicles')) { if (path.endsWith('/master-data/vehicles')) {
assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase() assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase()
@@ -232,6 +299,48 @@ test('native preparation edits existing master then submits both reviews, discov
assert.ok(s.assertions.every(a => a.passed)) assert.ok(s.assertions.every(a => a.passed))
}) })
test('fixed customer prepares a real trip without creating vehicles, drivers or uploading licenses', async () => {
const s = server('fixed-master')
s.context.options = { fixedCustomerId: '123', fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] }
const proof = await prepare(s)
assert.equal(proof.reusedMasterData, true)
assert.equal(proof.preparation, 'VERIFIED')
assert.equal(proof.vehicleId, VID)
assert.equal(proof.driverId, DRIVER)
assert.equal(s.calls.some(call => call.file), false)
assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D)))
})
test('fixed fleet resources block missing, locally occupied and ineligible pairs without master writes', async () => {
const pair = { vehicleId: VID, driverId: DRIVER }
for (const fault of ['missing', 'occupied', 'vehicle-status', 'driver-qualification']) {
const proof = {}, calls = []
const context = { options: { fixedFleetResources: fault === 'missing' ? [] : [pair] },
createdResources: { fleetTrips: fault === 'occupied' ? [{ ...pair }] : [proof] },
async request(method, path) {
calls.push(path); assert.equal(method, 'GET')
return path.includes('/vehicles/') ? ok({ id: VID, status: fault === 'vehicle-status' ? 'DISPATCHED' : 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: fault !== 'driver-qualification', reason: 'ELIGIBLE' } })
}, apiSuccess(_m, _l, response) { return response.body.data } }
await assert.rejects(reuseFleetResources(context, 'test', proof), /固定车辆司机/)
if (['missing', 'occupied'].includes(fault)) assert.equal(calls.length, 0)
}
})
test('verified resource closure makes a fixed pair available again', async () => {
const proof = {}
const context = { options: { fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] },
createdResources: { fleetTrips: [{ vehicleId: VID, driverId: DRIVER, closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, proof] },
async request(method, path) {
assert.equal(method, 'GET')
return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
}, apiSuccess(_m, _l, response) { return response.body.data } }
await reuseFleetResources(context, 'test', proof)
assert.equal(proof.vehicleId, VID)
assert.equal(proof.driverId, DRIVER)
})
test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => { test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => {
const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED' const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED'
const proof = await prepare(s) const proof = await prepare(s)
@@ -253,7 +362,7 @@ test('authoritative uppercase vehicle plate discovers both the prepared and subs
assert.equal(proof.tripNo, 'TR-A') assert.equal(proof.tripNo, 'TR-A')
s.trip.status = 'IN_TRANSIT' s.trip.status = 'IN_TRANSIT'
const closure = await finishFleetTrip(s.context, 'native', proof) const closure = await finishFleetTrip(s.context, 'native', proof)
assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined) assert.equal(closure.nextDispatch.marker, marker); assert.deepEqual(closure.nextDispatch.vehicle, proof.vehicle)
assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED') assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED')
const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?'))
assert.equal(queries.length, 2) assert.equal(queries.length, 2)
@@ -43,14 +43,14 @@ async function command(context, module, method, path, body, label, token, expect
return data return data
} }
// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。 // 按审核后权威车辆快照分页,再核对真实资源及本轮排车唯一绑定。
async function discoverTrip(context, module, proof) { async function discoverTrip(context, module, proof) {
// 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。 const vehiclePlate = proof.vehicle?.plate
const vehiclePlate = proof.marker.toUpperCase() if (typeof vehiclePlate !== 'string' || !vehiclePlate.trim()) throw Error('审核后的车辆快照缺少真实车牌')
const rows = [], seen = new Set(); let total const rows = [], seen = new Set(); let total
for (let page = 1; ; page++) { for (let page = 1; ; page++) {
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId }) const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录') const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录')
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联') || Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
total ??= Number(data.total) total ??= Number(data.total)
@@ -79,7 +79,25 @@ async function discoverTrip(context, module, proof) {
Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion }) Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion })
} }
/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */ export async function reuseFleetResources(context, module, proof, arrival) {
const pairs = context.options.fixedFleetResources
const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof
&& item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
const pair = Array.isArray(pairs) && pairs.find(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId))
&& /^[1-9][0-9]*$/.test(id(item?.driverId))
&& !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId)))
if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态')
const driver = await command(context, module, 'GET', `${BASE}/drivers/${id(pair.driverId)}?expectedEnd=${encodeURIComponent(proof.expectedDriverEndAt)}`, undefined, '核对固定司机至任务结束的资格')
if (id(vehicle.id) !== id(pair.vehicleId) || vehicle.status !== 'IN_SERVICE' || vehicle.region !== 'OVERSEAS' || vehicle.ownership !== 'OWNED'
|| id(driver.id) !== id(pair.driverId) || driver.active !== true || driver.reviewStatus !== 'APPROVED'
|| driver.details?.ownership !== 'OWNED' || driver.qualification?.canDispatch !== true || driver.qualification?.reason !== 'ELIGIBLE')
throw Error('固定车辆司机状态或资格不符;不创建、启用或修复主档')
Object.assign(proof, { vehicleId: id(vehicle.id), driverId: id(driver.id), reusedMasterData: true })
}
/** 固定客户复用已配置主档;非固定生命周期场景才建立专用主档。 */
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) { export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓') if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`, const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`,
@@ -90,6 +108,9 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
version(before.recordVersion) version(before.recordVersion)
if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0 if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0
|| safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核') || safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核')
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
if (context.options?.fixedCustomerId) await reuseFleetResources(context, module, proof, arrival)
if (before.stage === 'LOADING') { if (before.stage === 'LOADING') {
proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' } proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' }
await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前') await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前')
@@ -97,6 +118,7 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
} }
if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState)) if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState))
throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派') throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派')
if (!context.options?.fixedCustomerId) {
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
@@ -104,9 +126,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
}, '建立本轮TEST车辆主档及容量') }, '建立本轮TEST车辆主档及容量')
proof.vehicleId = id(vehicle.id) proof.vehicleId = id(vehicle.id)
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
await prepareQualifiedDriver(context, module, proof, arrival) await prepareQualifiedDriver(context, module, proof, arrival)
}
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
publishLooseboardDemand: before.publishLooseboardDemand, publishLooseboardDemand: before.publishLooseboardDemand,
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
@@ -222,17 +243,69 @@ async function verifyPreDepartureReassignment(context, module, proof) {
state.status = 'REJECTED_WITHOUT_MUTATION' state.status = 'REJECTED_WITHOUT_MUTATION'
} }
export const LOADING_PHOTO_SLOTS = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
export async function ensureFleetLoadingDocuments(context, module, proof, token) {
const path = `${token ? PDA : DISPATCH}/${encodeURIComponent(proof.dispatchNo)}/loading-documents`
const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token)
let documents = await read()
const initialStage = documents.stage
const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId)))
const validate = value => {
if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage)
|| value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配')
}
validate(documents)
proof.loadingDocuments ??= { syntheticTestMaterials: true, uploads: [] }
for (const slot of LOADING_PHOTO_SLOTS) {
if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue
if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力')
const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`,
name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' }
if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发')
proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.()
let response
try { response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png', content }, token) }
catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
Object.assign(upload, { status: response?.http === 200 && response?.body?.code ? 'RESPONDED' : 'UNKNOWN',
http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
await context.checkpoint?.()
const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车')
const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) }
const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token)
validate(bound)
check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
const replay = await command(context, module, 'PUT', `${path}/${slot}`, body, '原键重放装车照片绑定', token)
check(context, module, '照片绑定重放恢复原回执', JSON.stringify(replay) === JSON.stringify(bound))
documents = await read(); validate(documents)
check(context, module, '装车资料未齐不得提前发车', documents.stage === (initialStage === 'AFTER_LOADING' && documents.complete ? 'DELIVERING' : initialStage))
check(context, module, '装车照片已持久化', documents.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
}
check(context, module, '五个装车照片框均已保存', documents.complete && complete(documents))
proof.loadingDocuments.result = documents
await context.checkpoint?.()
return documents
}
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) { export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) {
if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明')
if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试')
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}`
const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken) let preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken)
if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`) || preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车') || actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex })) const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex }))
await verifyPreDepartureReassignment(context, module, proof) await verifyPreDepartureReassignment(context, module, proof)
await ensureFleetLoadingDocuments(context, module, proof, pdaToken)
const refreshed = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '照片保存后回查装车资格', pdaToken)
check(context, module, '资料保存不提前装车或改变实载清单', refreshed.dispatchNo === proof.dispatchNo
&& refreshed.status === 'ACTIVE' && refreshed.stage === 'LOADING' && refreshed.pickingReconciled === true
&& refreshed.loadingComplete?.allowed === true && refreshed.manifestHash === preview.manifestHash)
preview = refreshed
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
@@ -310,7 +383,7 @@ export async function finishFleetTrip(context, module, proof) {
state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源') state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源')
if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号') if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号')
state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId, state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId,
marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId } marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId, vehicle: proof.vehicle }
await discoverTrip(context, module, state.nextDispatch) await discoverTrip(context, module, state.nextDispatch)
if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录') if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录')
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
@@ -16,7 +16,7 @@ test('manual loading-complete modules are optional and require a source report',
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--password', 'password-value', '--modules', module,
'--manual-loading-complete-source-report', 'source.json']) '--manual-loading-complete-source-report', 'source.json'])
assert.deepEqual(options.modules, [module]) assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
assert.equal(validateExecutionOptions(options), options) assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }), assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }),
/manual-loading-complete-source-report/) /manual-loading-complete-source-report/)
@@ -34,7 +34,7 @@ test('fixture defaults to six orders while verifier requires an explicit report
'--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json', '--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json',
'--manual-split-plan-id', '123', '--manual-split-plan-id', '123',
]) ])
assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify']) assert.deepEqual(verifyOptions.modules, ['merchant-qualification-fixture', 'manual-order-splitting-verify'])
assert.equal(validateExecutionOptions(verifyOptions), verifyOptions) assert.equal(validateExecutionOptions(verifyOptions), verifyOptions)
assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }), assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }),
/manual-split-plan-id/) /manual-split-plan-id/)
@@ -15,7 +15,7 @@ test('manual PDA batch modules are optional and require a source report', () =>
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json']) '--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json'])
assert.deepEqual(options.modules, [module]) assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
assert.equal(validateExecutionOptions(options), options) assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }), assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }),
/manual-pda-batch-source-report/) /manual-pda-batch-source-report/)
@@ -15,7 +15,7 @@ test('manual PDA-ready modules are optional and require a source report', () =>
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json']) '--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json'])
assert.deepEqual(options.modules, [module]) assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
assert.equal(validateExecutionOptions(options), options) assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }), assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }),
/manual-pda-source-report/) /manual-pda-source-report/)
@@ -15,7 +15,7 @@ test('manual preload modules are optional and require an explicit source report'
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
'--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json']) '--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json'])
assert.deepEqual(options.modules, [module]) assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
assert.equal(validateExecutionOptions(options), options) assert.equal(validateExecutionOptions(options), options)
assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }), assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }),
/manual-preload-source-report/) /manual-preload-source-report/)
@@ -86,7 +86,7 @@ test('fixed resource runner excludes lifecycle handlers without marking them pas
test('both request entrypoints reject customer creation before network access', async () => { test('both request entrypoints reject customer creation before network access', async () => {
const runner = new AcceptanceRun({ environment: 'test' }) const runner = new AcceptanceRun({ environment: 'test' })
for (const method of ['request', 'requestWithBody']) { for (const method of ['request', 'requestWithBody']) {
for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) { for (const path of ['/api/auth/register', '/api/auth/register/?source=test', '/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) {
await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/) await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/)
} }
} }
@@ -45,12 +45,13 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot
assert.ok(source.includes(value), value) assert.ok(source.includes(value), value)
}) })
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => { test('fulfillment checks PDF signature, automatic documents and immutable stock around loading replay', async () => {
const source = await read('phase5-overseas-fulfillment.mjs') const source = await read('phase5-overseas-fulfillment.mjs')
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", 'await readAutomaticDispatchDelivery(',
'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)', 'value?.documents?.deliveries', 'value?.documents?.outbounds', 'await verifyAutomaticDeparture(',
'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)']) 'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)'])
assert.ok(source.includes(value), value) assert.ok(source.includes(value), value)
assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/)
const helper = await read('fleet-trip-fulfillment.mjs') const helper = await read('fleet-trip-fulfillment.mjs')
for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)', for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)',
'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command', 'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command',
@@ -33,7 +33,8 @@ test('offline orchestration model distinguishes processing replay recovery and c
test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => { test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => {
const start = loadingPickService.indexOf('public Task complete(') const start = loadingPickService.indexOf('public Task complete(')
const end = loadingPickService.indexOf('/** Called only by the completion worker', start) const end = loadingPickService.indexOf('public void processCompletion(', start)
assert.ok(start >= 0 && end > start)
const block = loadingPickService.slice(start, end) const block = loadingPickService.slice(start, end)
assert.match(block, /LoadingCompletionCommandEntity/) assert.match(block, /LoadingCompletionCommandEntity/)
assert.match(block, /acceptCompletion/) assert.match(block, /acceptCompletion/)
@@ -1,6 +1,88 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs' import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff } from './phase4-overseas-pda.mjs'
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
test('fixed PDA actors are leased separately and only logged out, never modified', async () => {
const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' }))
const calls = [], gaps = []
const context = { options: { fixedCustomerId: '101', fixedPdaActors: actors },
async request(method, path, body) {
calls.push({ method, path })
let data = {}
if (path.includes('/iam/users?')) data = { total: 2, records: actors.map(actor => ({ ...actor,
overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] })) }
if (path === '/api/auth/login') data = { accessToken: body.loginId, refreshToken: `refresh-${body.loginId}` }
return { http: 200, body: { code: '200', data } }
},
apiSuccess(_module, _name, result) { return result.body.data }, addStep() {},
addCoverageGap(...args) { gaps.push(args) },
}
const primary = await createPicker(context, 1, '91', { role: { id: '7' } })
const secondary = await createPicker(context, 2, '91', { role: { id: '7' } })
assert.notEqual(primary.membershipId, secondary.membershipId)
assert.equal(await createPicker(context, 3, '91', { role: { id: '7' } }), null)
const handoff = pickerSessionHandoff(primary)
assert.equal(handoff.reused, true)
assert.equal(handoff.password, undefined)
await verifyPdaWarehouseRevocation(context, handoff)
assert.equal(gaps.length, 1)
await disablePicker(context, handoff, 1)
await disablePicker(context, primary, 1)
await disablePicker(context, secondary, 2)
assert.equal(context.fixedPdaLeases.size, 0)
assert.equal(calls.filter(call => call.path === '/api/auth/logout').length, 2)
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
})
test('missing fixed PDA pool stops before network or phone allocation', async () => {
const context = { options: { fixedCustomerId: '101' }, addStep() {},
request() { throw Error('unexpected HTTP') }, findAvailablePhone() { throw Error('unexpected allocation') } }
assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null)
})
test('failed PDA projection checks logout and retains leases on rejected or uncertain logout', async () => {
for (const logout of ['success', 'rejected', 'throws']) {
const actor = { membershipId: '11', warehouseId: '91', username: 'fixture-actor', password: 'fixture-only' }
const steps = [], calls = []
const context = { options: { fixedCustomerId: '101', fixedPdaActors: [actor] },
async request(method, path) {
calls.push(path)
if (path.includes('/iam/users?')) return { body: { data: { records: [{ ...actor,
overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] }] } } }
if (path === '/api/auth/login') return { body: { data: { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } } }
if (path === '/api/auth/logout' && logout === 'throws') throw Error('network failure')
return { http: 200, body: { code: path === '/api/auth/logout' && logout === 'success' ? '200' : 'A0403' } }
},
apiSuccess(_module, _name, response) { return response.body.data },
addStep(...args) { steps.push(args) },
}
assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null)
assert.equal(calls.filter(path => path === '/api/auth/logout').length, 1)
assert.equal(context.fixedPdaLeases.has('11'), logout !== 'success')
assert.ok(steps.some(step => step[1].includes('核验失败') && step[3] === false))
if (logout !== 'success') {
assert.ok(steps.some(step => step[1].includes('退出固定PDA') && step[3] === false))
const before = calls.length
assert.equal(await createPicker(context, 2, '91', { role: { id: '7' } }), null)
assert.equal(calls.length, before)
}
}
})
test('logout exception does not prevent remaining fixed actors from being cleaned up', async () => {
const calls = []
const context = { fixedPdaLeases: new Set(['11', '12']), addStep() {},
async request(_method, _path, body) {
calls.push(body.refreshToken)
if (body.refreshToken === 'first') throw Error('network failure')
return { http: 200, body: { code: '200' } }
} }
await disablePicker(context, { membershipId: '11', refreshToken: 'first', reused: true }, 1)
await disablePicker(context, { membershipId: '12', refreshToken: 'second', reused: true }, 2)
assert.deepEqual(calls, ['first', 'second'])
assert.deepEqual([...context.fixedPdaLeases], ['11'])
})
test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => { test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => {
const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' } const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' }
@@ -367,6 +367,13 @@ export async function run(context) {
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4, approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS) `status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
let customsAgentId
if (context.options?.fixedCustomerId) {
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
const existing = await loadSupplier(context, 'CUSTOMS')
if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商')
customsAgentId = id(existing.id)
} else {
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳', name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳',
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000', legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
@@ -374,15 +381,16 @@ export async function run(context) {
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null, businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
}) })
const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商', customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
supplierCreateResponse, `marker=${context.marker}`, OPTIONS)) supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
}
if (!customsAgentId) return if (!customsAgentId) return
const supplierDetailResponse = await context.request('GET', const supplierDetailResponse = await context.request('GET',
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`) `/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS) const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse, context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE' id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
&& customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker, && customsAgent?.category === 'CUSTOMS' && (context.options?.fixedCustomerId || customsAgent?.remark === context.marker),
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS) `customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE' if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|| customsAgent?.category !== 'CUSTOMS') return || customsAgent?.category !== 'CUSTOMS') return
@@ -42,6 +42,12 @@ function failure(response) {
function containerRecord(value) { return value?.container } function containerRecord(value) { return value?.container }
async function ensureMatsonSupplier(context, records) { async function ensureMatsonSupplier(context, records) {
if (context.options?.fixedCustomerId) {
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' })
if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档')
return supplier
}
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
if (existing?.status === 'ACTIVE') return existing if (existing?.status === 'ACTIVE') return existing
if (existing?.id) { if (existing?.id) {
@@ -3,10 +3,40 @@ import assert from 'node:assert/strict'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-business.mjs' import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
test('fixed partner warehouse is reused and never retired', async () => {
const calls = []
const context = { options: { fixedCustomerId: '123' }, marker: 'RUN', createdResources: {},
async request(method, path) { calls.push({ method, path }); return { body: { data: [{ warehouseId: '456' }] } } },
apiSuccess(_module, _name, response) { return response.body.data },
}
const warehouse = await createPartnerWarehouse(context)
assert.equal(warehouse.id, '456')
await retirePartnerWarehouse(context, warehouse)
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'GET')
})
const here = path.dirname(fileURLToPath(import.meta.url)) const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8') const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
const context = fixture(), request = context.request.bind(context), pages = []
context.options = { fixedCustomerId: '10001' }
context.request = async (method, path, ...args) => {
if (path.startsWith('/api/platform/v1/iam/tenants?')) {
pages.push(path)
if (path.includes('page=1&')) return response({ ...page([{ id: '9999', status: 'ACTIVE' }]), size: 200, total: 201, pages: 2 })
return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }]))
}
return request(method, path, ...args)
}
await run(context)
assert.equal(pages.length, 2)
assert.ok(context.createdResources.phase4OverseasBusiness.purchase)
assert.ok(context.calls.filter(call => call.path.includes('/warehouse-admin')).every(call => call.method === 'GET'))
})
function response(data, code = '200') { function response(data, code = '200') {
return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data },
requestId: `request-${code}`, traceId: `trace-${code}` } requestId: `request-${code}`, traceId: `trace-${code}` }
@@ -64,7 +64,15 @@ function partnerWarehousePayload(context) {
} }
} }
async function createPartnerWarehouse(context) { export async function createPartnerWarehouse(context) {
if (context.options?.fixedCustomerId) {
const result = await overseas(context, 'GET', '查询可复用采购合作仓', `${BASE}/overseas-procurements/warehouse-options`)
const candidate = Array.isArray(result.data) ? result.data.find(item => longId(item?.warehouseId)) : null
if (!candidate) return blocker(context, '缺少可复用采购合作仓', result.response, '不自动创建仓库')
const resource = { marker: context.marker, id: id(candidate.warehouseId), reused: true, finalStatus: 'ACTIVE', retained: true }
ownedRegistry(context).partnerWarehouse = resource
return resource
}
const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context)) const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context))
const warehouseId = id(created.data) const warehouseId = id(created.data)
const valid = longId(warehouseId) const valid = longId(warehouseId)
@@ -140,7 +148,14 @@ async function prerequisites(context, session) {
if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response, if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response,
'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期') 'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期')
const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId) const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId)
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) let activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
const customerPageSize = Number(tenants.data?.size || 200)
for (let page = 2; !activeCustomer && (page - 1) * customerPageSize < Number(tenants.data?.total); page += 1) {
const next = await domesticGet(context, '分页查询固定客户', `/api/platform/v1/iam/tenants?page=${page}&size=200&status=ACTIVE`)
assertPage(context, '固定客户候选', next)
activeCustomer = records(next.data).find(item => id(item?.id) === fixtureCustomerId && item.status === 'ACTIVE')
if (!records(next.data).length) break
}
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response, if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`) `marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
const token = await loginMerchant(context, activeCustomer, session) const token = await loginMerchant(context, activeCustomer, session)
@@ -490,8 +505,8 @@ async function looseboardFlow(context, customerId) {
} }
} }
async function retirePartnerWarehouse(context, resource) { export async function retirePartnerWarehouse(context, resource) {
if (!resource?.id) return if (!resource?.id || resource.reused) return
const retired = await context.request('PATCH', const retired = await context.request('PATCH',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`) `/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`)
const passed = retired?.http === 200 && codeOf(retired) === '200' const passed = retired?.http === 200 && codeOf(retired) === '200'
@@ -7,6 +7,21 @@ import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-r
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8') const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
test('current plan on second page is used instead of historical first-page plans', async () => {
const context = fixture(), request = context.request.bind(context), pages = []
context.request = async (method, path, ...args) => {
if (path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) {
pages.push(path)
if (path.includes('page=1&')) return response({ records: [{ planId: '9999', planNo: 'OLD', customerId: '2001' }], size: 200, total: 201 })
}
return request(method, path, ...args)
}
await run(context)
assert.equal(pages.length, 2)
assert.ok(context.steps.find(step => step.name === '海外入仓费用关联真实计划')?.passed)
assert.equal(context.calls.some(call => call.method === 'POST' && call.body?.businessId === '9999'), false)
})
test('inbound fees cannot select historical plans or another customer under a fixed account', () => { test('inbound fees cannot select historical plans or another customer under a fixed account', () => {
const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' } const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' }
const old = { planId: '3999', planNo: 'OLD', customerId: '2001' } const old = { planId: '3999', planNo: 'OLD', customerId: '2001' }
@@ -81,9 +81,17 @@ async function resolveFixtures(context) {
const inboundCustomerId = id(pda?.customerId) const inboundCustomerId = id(pda?.customerId)
let inbound = null let inbound = null
if (longId(inboundCustomerId)) { if (longId(inboundCustomerId)) {
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', let listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
const plan = selectCurrentInboundPlan(listed.data, pda, context.marker) let plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
const total = Number(listed.data?.total || 0)
const pageSize = Number(listed.data?.size || 200)
for (let page = 2; !plan && (page - 1) * pageSize < total; page += 1) {
listed = await overseas(context, 'GET', '分页查询本轮入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=${page}&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
if (!rows(listed.data).length) break
}
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response, context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
Boolean(plan) && longId(plan?.planId || plan?.id), Boolean(plan) && longId(plan?.planId || plan?.id),
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) `planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
@@ -5,7 +5,24 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs' import { run, remainingRelabelItem, selectPhysicalRelabelSources, fundRelabelWallet, RELABEL_CHARGE_AMOUNT } from './phase4-overseas-pda-operations.mjs'
test('fixed customer relabel wallet only reads existing available funds', async () => {
assert.equal(RELABEL_CHARGE_AMOUNT, 0.01)
for (const availableBalance of ['0.01', '0.00', undefined]) {
const calls = [], steps = []
const context = { options: { fixedCustomerId: '123' },
async request(method, path) {
calls.push({ method, path })
return { http: 200, body: { code: '200', data: [{ customerId: '123', accountVersion: 1, actualBalance: 100, availableBalance }] } }
},
addStep(...args) { steps.push(args) },
}
assert.equal(await fundRelabelWallet(context, '123'), availableBalance === '0.01')
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'GET')
}
})
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => { test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [ const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
@@ -69,7 +86,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
]) assert.ok(source.includes(endpoint), endpoint) ]) assert.ok(source.includes(endpoint), endpoint)
for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409',
'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED', 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED',
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:', 'chargeAmount: RELABEL_CHARGE_AMOUNT', 'cartonMark:', 'fbaCartonMark:',
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map', 'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark', 'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
@@ -2,6 +2,7 @@ import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
const MODULE = 'phase4-overseas-pda-operations' const MODULE = 'phase4-overseas-pda-operations'
export const RELABEL_CHARGE_AMOUNT = 0.01
const SCENARIO = 'phase4-overseas-pda-p2-p5' const SCENARIO = 'phase4-overseas-pda-p2-p5'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
@@ -63,7 +64,7 @@ async function poll(action, predicate, attempts = 60) {
return { result, attempts } return { result, attempts }
} }
async function fundRelabelWallet(context, customerId) { export async function fundRelabelWallet(context, customerId) {
const accountResponse = await context.request('GET', const accountResponse = await context.request('GET',
`/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`) `/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
const account = success(accountResponse) const account = success(accountResponse)
@@ -71,6 +72,13 @@ async function fundRelabelWallet(context, customerId) {
context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse, context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse,
Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS) Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS)
if (!account) return false if (!account) return false
if (context.options?.fixedCustomerId) {
const sufficient = account.availableBalance != null && Number.isFinite(Number(account.availableBalance))
&& Number(account.availableBalance) >= RELABEL_CHARGE_AMOUNT
context.addStep(MODULE, '复用已有可用余额;不足时禁止自动充值', accountResponse,
sufficient, `availableBalance=${account.availableBalance ?? 'missing'}`, OPTIONS)
return sufficient
}
const recharge = await context.request('POST', const recharge = await context.request('POST',
`/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, { `/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, {
type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10), type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10),
@@ -415,7 +423,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
const createBody = { const createBody = {
customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId), customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId),
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker, contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: RELABEL_CHARGE_AMOUNT, remark: context.marker,
idempotencyKey: key(context, 'RELABEL-CREATE'), idempotencyKey: key(context, 'RELABEL-CREATE'),
deliveryMethod: 'TRUCK', printShippingLabel: false, deliveryMethod: 'TRUCK', printShippingLabel: false,
items: sourceLines.map(({ source, position, packageCount }, index) => { items: sourceLines.map(({ source, position, packageCount }, index) => {
@@ -316,7 +316,47 @@ export async function resolvePickerAccess(context) {
return { role, operate } return { role, operate }
} }
async function reusePicker(context, index, overseasWarehouseId, access) {
const actors = context.options.fixedPdaActors
context.fixedPdaLeases ??= new Set()
const actor = Array.isArray(actors) && actors.find(item => id(item?.warehouseId) === id(overseasWarehouseId)
&& longId(item.membershipId) && nonBlank(item.username) && nonBlank(item.password)
&& !context.fixedPdaLeases.has(id(item.membershipId)))
if (!actor) {
context.addStep(MODULE, `固定PDA操作员${index}前置`, null, false, '缺少该仓未占用的固定账号,禁止自动创建', OPTIONS)
return null
}
let token, refreshToken
try {
let user
for (let page = 1; ; page++) {
const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
const data = context.apiSuccess(MODULE, '核对固定PDA账号', result, '', OPTIONS)
user = data?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
if (user || !data?.records?.length || page * Number(data.size || 100) >= Number(data.total || 0)) break
}
if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0'
|| id(user.overseasWarehouseId) !== id(overseasWarehouseId)
|| !user.roleIds?.map(id).includes(id(access.role.id))) throw Error('固定PDA账号状态、角色或仓绑定不匹配')
const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '')
const session = context.apiSuccess(MODULE, '登录固定PDA账号', login, '', OPTIONS)
token = session?.accessToken; refreshToken = session?.refreshToken
if (token || refreshToken) context.fixedPdaLeases.add(id(actor.membershipId))
if (!token || !refreshToken) throw Error('固定PDA登录未返回完整会话')
const projection = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
if (!success(projection) && !notFound(projection)) throw Error('固定PDA作业权限检查失败')
return { membershipId: id(actor.membershipId), username: actor.username, token, refreshToken, reused: true }
} catch (error) {
if (token && refreshToken) await disablePicker(context, {
membershipId: id(actor.membershipId), token, refreshToken, reused: true,
}, index)
context.addStep(MODULE, `固定PDA操作员${index}核验失败`, null, false, error.message, OPTIONS)
return null
}
}
export async function createPicker(context, index, overseasWarehouseId, access) { export async function createPicker(context, index, overseasWarehouseId, access) {
if (context.options?.fixedCustomerId) return reusePicker(context, index, overseasWarehouseId, access)
const role = access.role const role = access.role
const suffix = pickerFixtureSuffix(context) const suffix = pickerFixtureSuffix(context)
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
@@ -359,13 +399,26 @@ export async function createPicker(context, index, overseasWarehouseId, access)
export function pickerSessionHandoff(picker) { export function pickerSessionHandoff(picker) {
if (!picker) return null if (!picker) return null
const { membershipId, username, token, refreshToken, password, userUpdate } = picker const { membershipId, username, token, refreshToken, password, userUpdate, reused } = picker
return { membershipId, username, token, refreshToken, password, return { membershipId, username, token, refreshToken, password, reused,
userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined } userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined }
} }
export async function disablePicker(context, picker, index) { export async function disablePicker(context, picker, index) {
if (!longId(picker?.membershipId)) return if (!longId(picker?.membershipId)) return
if (picker.reused) {
if (!context.fixedPdaLeases?.has(id(picker.membershipId))) return
let response
try {
response = await context.request('POST', '/api/auth/logout', { refreshToken: picker.refreshToken }, picker.token)
} catch {
context.addStep(MODULE, `退出固定PDA操作员${index}会话失败`, null, false, '退出请求异常;保留账号占用,继续清理其他会话', OPTIONS)
return
}
context.addStep(MODULE, `退出固定PDA操作员${index}会话,不停用账号`, response, success(response), trace(response), OPTIONS)
if (success(response)) context.fixedPdaLeases.delete(id(picker.membershipId))
return
}
const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' }) const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' })
const disabled = success(response) && response.body?.data?.status === 'DISABLED' const disabled = success(response) && response.body?.data?.status === 'DISABLED'
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS) context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
@@ -1,7 +1,7 @@
import test from 'node:test' import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises' import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
@@ -64,21 +64,20 @@ test('phase-five fulfillment is a registered default HTTP module after the real
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => { test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement', "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds',
'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash',
'recordVersion: version(detail.data.recordVersion)', 'positionId: id', 'recordVersion: version(detail.data.recordVersion)', 'positionId: id',
'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) { 'ready.data.counts?.shippedPackageCount', 'outboundRef.outboundNo']) {
if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/) if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/)
else assert.ok(source.includes(token), token) else assert.ok(source.includes(token), token)
} }
assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/) assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/)
assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/) assert.match(source, /data.status === 'ACTIVE' && data.stage === 'DELIVERING'/)
assert.match(source, /departed.data.stage === 'DELIVERING'/) assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/)
const run = source.slice(source.indexOf('export async function run')) const run = source.slice(source.indexOf('export async function run'))
assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll(')) assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll('))
assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading(')) assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading('))
assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery(')) assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await readAutomaticDispatchDelivery('))
assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness(')) assert.ok(run.indexOf('await readAutomaticDispatchDelivery(') < run.indexOf('await verifyAutomaticDeparture('))
assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip(')) assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip('))
assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest')) assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest'))
}) })
@@ -133,9 +132,8 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo
}) })
test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => {
assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/)
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/)
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/) assert.doesNotMatch(source, /DISPATCH-DEPART/)
assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) assert.match(source, /addCoverageGap\(MODULE, SCENARIO/)
assert.match(source, /deploymentBlocker: true/) assert.match(source, /deploymentBlocker: true/)
}) })
@@ -497,13 +495,13 @@ test('inventory evidence captures zero drift independently for every balance buc
}) })
test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => { test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => {
const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254740993',
pickingReconciled: true, actions: { DEPART: { allowed: true } } } pickingReconciled: true, counts: { packageCount: 2, shippedPackageCount: 2 } }
for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' }, for (const change of [null, { stage: 'LOADING' }, { stage: 'AFTER_LOADING' }, { status: 'CANCELLED' },
{ dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false }, { dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false },
{ actions: { DEPART: { allowed: false } } }]) { { counts: { packageCount: 2, shippedPackageCount: 1 } }]) {
const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change })) const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change }))
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null) assert.equal(Boolean(await verifyAutomaticDeparture(context, 'PDA', 'DS-1')), change == null)
assert.equal(context.calls.length, 2) assert.equal(context.calls.length, 2)
assert.equal(context.calls.some(call => call.method !== 'GET'), false) assert.equal(context.calls.some(call => call.method !== 'GET'), false)
} }
@@ -787,50 +785,56 @@ test('native PDA scans use exact current versions including zero, immutable repl
assert.deepEqual(context.assertions.filter(row => !row.passed), []) assert.deepEqual(context.assertions.filter(row => !row.passed), [])
}) })
function nativeArrangementPreview(recordVersion = '0') { function automaticDispatch(recordVersion = '0') {
return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO', return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true,
loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({ counts: { packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 2 },
groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, documents: { outbounds: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, status: 'OUTBOUNDED', packageCount: 1 })),
groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE', deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, deliveryNo: `DL-${customerId}`,
requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) } destination: { key: 'FBA:ONT8' }, status: 'DELIVERING', packageCount: 1 })) } }
} }
test('automatic delivery reader preserves customer grouping and performs no writes', async () => {
test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => {
for (const recordVersion of ['0', '9007199254740993']) { for (const recordVersion of ['0', '9007199254740993']) {
const preview = nativeArrangementPreview(recordVersion) const current = automaticDispatch(recordVersion)
let original, result const context = contextFor((method, path) => { assert.equal(method, 'GET'); assert.ok(path.endsWith('/DS-1')); return ok(current) })
const context = contextFor((method, path, body) => { const result = await readAutomaticDispatchDelivery(context, 'DS-1')
if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) } assert.equal(result.data.recordVersion, recordVersion)
assert.match(path, /transfer-delivery$/) assert.deepEqual(result.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200'])
if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) } assert.equal(context.calls.length, 1)
original = structuredClone(body) }
assert.equal(body.recordVersion, recordVersion)
assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD')
assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200'])
assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE'))
result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994',
result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({
customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })),
deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`,
deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } }
return ok(result)
}) })
const arranged = await arrangeDispatchDelivery(context, 'DS-1') test('automatic delivery reader rejects incomplete departure and customer or quantity drift', async () => {
assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200']) for (const mutate of [v => { v.stage = 'AFTER_LOADING' }, v => { v.status = 'CANCELLED' },
assert.equal(context.calls.filter(call => call.method === 'POST').length, 2) v => { v.documents.deliveries[0].customerId = '999' }, v => { v.documents.outbounds[0].packageCount = 2 },
assert.deepEqual(context.assertions.filter(row => !row.passed), []) v => { v.documents.deliveries = [] }, v => { v.documents.deliveries[1].deliveryNo = 'DL-100' },
v => { v.counts.shippedPackageCount = 1 }, v => { v.documents.outbounds[0].status = 'WAIT_OUTBOUND' }]) {
const current = automaticDispatch(); mutate(current)
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null)
assert.ok(context.assertions.some(row => !row.passed))
} }
}) })
test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => { test('automatic delivery cannot hide duplicated customer or destination groups behind conserved totals', async () => {
for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' }, for (const sameOutbound of [false, true]) {
value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' }, const current = automaticDispatch()
value => { value.groups = [] }, value => { value.arrange.allowed = false }, current.documents.outbounds[1].customerId = '100'
value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) { current.documents.deliveries[1].customerId = '100'
const preview = nativeArrangementPreview(); mutate(preview) if (sameOutbound) {
const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) }) current.documents.outbounds.pop()
assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null) current.documents.outbounds[0].packageCount = 2
assert.ok(context.assertions.some(row => !row.passed)) current.documents.deliveries[1].outboundNo = 'OB-100'
} else current.documents.deliveries[1].destination.key = 'FBA:ONT9'
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null)
}
})
test('automatic delivery accepts canonical Long quantities and rejects coercible non-quantities', async () => {
for (const quantity of ['1', true, '01', '1e0', ' 1 ', '9007199254740993']) {
const current = automaticDispatch()
current.documents.deliveries[0].packageCount = quantity
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
assert.equal(Boolean(await readAutomaticDispatchDelivery(context, 'DS-1')), quantity === '1')
} }
}) })
@@ -133,14 +133,6 @@ async function merchant(context, token, method, name, path, body = undefined) {
return { response, data } return { response, data }
} }
function transferDeliveries(data) {
return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
}
function deliveryNumbers(data) {
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
}
export function dispatchDestinationConservation(detail, fixture, deliveries = null) { export function dispatchDestinationConservation(detail, fixture, deliveries = null) {
const codes = values => values.map(value => id(value).trim().toUpperCase()) const codes = values => values.map(value => id(value).trim().toUpperCase())
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
@@ -858,7 +850,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId) const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
if (!await pickAll(context, token, relabel.dispatchNo)) return if (!await pickAll(context, token, relabel.dispatchNo)) return
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements) await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo) const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
if (!transferred) return if (!transferred) return
const customerId = id(relabel.customerId) const customerId = id(relabel.customerId)
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
@@ -869,7 +861,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
} }
async function loginMerchant(context, customerId) { async function loginMerchant(context, customerId) {
@@ -909,17 +901,18 @@ function fixture(context) {
return { dispatch, receiving } return { dispatch, receiving }
} }
export async function verifyDepartureReadiness(context, token, dispatchNo) { export async function verifyAutomaticDeparture(context, token, dispatchNo) {
const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁', const ready = await pda(context, token, 'GET', '回查PDA自动发车结果',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段', const dispatch = await platform(context, 'GET', '独立回查平台自动发车结果',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const passed = success(ready.response) && success(dispatch.response) const passed = success(ready.response) && success(dispatch.response)
&& [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo && [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo
&& data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING' && data.status === 'ACTIVE' && data.stage === 'DELIVERING'
&& data.pickingReconciled === true && data.actions?.DEPART?.allowed === true) && data.pickingReconciled === true && Number(data.counts?.packageCount) > 0
&& Number(data.counts?.shippedPackageCount) === Number(data.counts?.packageCount))
&& String(ready.data.recordVersion) === String(dispatch.data.recordVersion) && String(ready.data.recordVersion) === String(dispatch.data.recordVersion)
context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response, context.assert(MODULE, '双端自动发车阶段与版本一致', ready.response,
passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS) passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS)
if (passed) version(ready.data.recordVersion) if (passed) version(ready.data.recordVersion)
return passed ? ready : null return passed ? ready : null
@@ -983,56 +976,30 @@ export async function pickAll(context, token, dispatchNo) {
return passed return passed
} }
export async function arrangeDispatchDelivery(context, dispatchNo) { export async function readAutomaticDispatchDelivery(context, dispatchNo) {
const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` const current = await platform(context, 'GET', '回查装车后自动派送单据', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`) const value = current.data, deliveries = value?.documents?.deliveries, outbounds = value?.documents?.outbounds
const value = preview.data const positive = count => (typeof count === 'number' || typeof count === 'string')
if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE' && /^[1-9][0-9]*$/.test(String(count)) && Number.isSafeInteger(Number(count))
|| value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true const passed = success(current.response) && value?.dispatchNo === dispatchNo && value.status === 'ACTIVE'
|| !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash) && value.stage === 'DELIVERING' && value.pickingReconciled === true && positive(value.counts?.packageCount)
|| !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey) && positive(value.counts.pickedPackageCount) && positive(value.counts.shippedPackageCount)
|| !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key) && Number(value.counts.pickedPackageCount) === Number(value.counts.packageCount)
|| !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) { && Number(value.counts.shippedPackageCount) === Number(value.counts.packageCount)
blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response) && Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0
return null
}
const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo),
cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash,
groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64),
carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null,
remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash,
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [],
fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service,
direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) }
const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body)
if (!success(transferred.response)) return null
const result = transferred.data?.result
const deliveries = transferDeliveries(transferred.data)
const outbounds = result?.outbounds
const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort()
const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort()
const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE'
&& transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo
&& result.cargoHash === body.cargoHash && deliveries.length === value.groups.length
&& new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length && new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length
&& JSON.stringify(customerGroups) === JSON.stringify(actualGroups) && new Set(outbounds.map(row => row.outboundNo)).size === outbounds.length
&& Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size && new Set(outbounds.map(row => id(row.customerId))).size === outbounds.length
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND' && new Set(deliveries.map(row => JSON.stringify([id(row.customerId), row.destination?.key]))).size === deliveries.length
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId)) && deliveries.every(row => nonBlank(row.deliveryNo) && nonBlank(id(row.customerId)) && nonBlank(row.destination?.key)
&& row.status === 'DELIVERING' && positive(row.packageCount)
&& outbounds.some(outbound => outbound.outboundNo === row.outboundNo && id(outbound.customerId) === id(row.customerId)))
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'OUTBOUNDED' && positive(row.packageCount)
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount)) && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount))
&& deliveries.every(row => row.status === 'WAIT_DELIVERY') && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(value.counts.packageCount)
context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS) context.assert(MODULE, '自动派送客户归属、单据状态和实发数量守恒', current.response, passed, trace(current.response), OPTIONS)
if (!passed) return null if (!passed) return null
const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body) return { ...current, outbounds, deliveries }
const stable = success(replay.response) && replay.data?.replayed === true
&& String(replay.data.recordVersion) === String(transferred.data.recordVersion)
&& JSON.stringify(replay.data.result) === JSON.stringify(result)
context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS)
if (!stable) return null
return { ...transferred, preview: value, outbounds, deliveries }
} }
/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ /** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */
@@ -1146,40 +1113,26 @@ export async function run(context) {
const primaryInventory = proofs.find(proof => proof.customerId === customerId) const primaryInventory = proofs.find(proof => proof.customerId === customerId)
if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation } if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation }
await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements) await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements)
transferred = await arrangeDispatchDelivery(context, dispatchNo) transferred = await readAutomaticDispatchDelivery(context, dispatchNo)
if (!transferred) return if (!transferred) return
deliveries = transferred.deliveries; outbounds = transferred.outbounds deliveries = transferred.deliveries; outbounds = transferred.outbounds
routeProof.deliveries = deliveries routeProof.deliveries = deliveries
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response, context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS) dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
if (!ready) return if (!ready) return
const departBody = { recordVersion: version(ready.data.recordVersion),
idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` }
const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0) const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo if (Number(ready.data.counts?.shippedPackageCount) !== expectedPackages) {
&& departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING' blocker(context, '自动发车数量', '实发数量与本轮扫码来源不一致', ready.response); return
&& Number(departed.data.result?.shippedPackages) === expectedPackages }
&& JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort())
context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS)
if (!departedCorrectly) return
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
const replayStable = success(departReplay.response) && departReplay.data?.replayed === true
&& String(departReplay.data.recordVersion) === String(departed.data.recordVersion)
&& JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result)
context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS)
if (!replayStable) return
for (const proof of proofs) { for (const proof of proofs) {
proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes,
`客户${proof.customerId}发车后库存`, proof.sourceBalanceIds) `客户${proof.customerId}发车后库存`, proof.sourceBalanceIds)
proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo) proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo)
const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances) const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances)
&& sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart) && sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS) context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
if (!unchanged) return if (!unchanged) return
} }
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。 // 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
@@ -621,11 +621,11 @@ function nativeFragmentFixture() {
async confirmFleetLoading(current, module, proof, token, actual) { async confirmFleetLoading(current, module, proof, token, actual) {
events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement) events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement)
if (!actual) throw new Error('实际测量缺少有效palletSpaces') if (!actual) throw new Error('实际测量缺少有效palletSpaces')
return '9007199254740998' departed = true; proof.loadingCommand = { replay: { replayed: true } }; return '9007199254740998'
}, },
async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery], async readAutomaticDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery],
outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } }, outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { documents: { deliveries: [delivery] } } } },
async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } }, async verifyAutomaticDeparture() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999', stage: 'DELIVERING', counts: { shippedPackageCount: 1 } } } },
async saveReceiptAndComplete(current, actual, customerId, index) { async saveReceiptAndComplete(current, actual, customerId, index) {
events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0) events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0)
signed = true signed = true
@@ -642,11 +642,11 @@ test('F40 fragment uses native creation, reviews, real PDA scan, explicit loadin
assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003') assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003')
assert.equal(result.signedAt, '2026-10-19T01:00:00') assert.equal(result.signedAt, '2026-10-19T01:00:00')
assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick', assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick',
'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish']) 'loading-complete', 'arrange', 'read-current-depart-version', 'receipt', 'normal-completed', 'finish'])
assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false) assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false)
assert.equal(f.calls.some(call => call.method === 'PUT'), false) assert.equal(f.calls.some(call => call.method === 'PUT'), false)
assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED') assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED')
assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true) assert.equal(f.rootProof.dispatches[0].loadingReplay.replayed, true)
assert.equal(f.context.steps.every(step => step.passed), true) assert.equal(f.context.steps.every(step => step.passed), true)
}) })
@@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving, import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
import { isDeepStrictEqual } from 'node:util' import { isDeepStrictEqual } from 'node:util'
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
@@ -484,11 +484,11 @@ async function fragment(context, source, rootOrderId, sequence) {
shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt,
palletLabelManifest, rootOrderId } palletLabelManifest, rootOrderId }
} }
const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery,
verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext }
export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) { export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) {
const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, const { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture,
saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides } saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides }
const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId) const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId)
context.marker = `${context.context.f40BaseMarker}-SEND-${index}` context.marker = `${context.context.f40BaseMarker}-SEND-${index}`
@@ -529,8 +529,8 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
dispatchProof.action = 'LOADING_COMPLETE' dispatchProof.action = 'LOADING_COMPLETE'
const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId)
await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements) await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements)
dispatchProof.action = 'ARRANGE_DELIVERY' dispatchProof.action = 'READ_AUTOMATIC_DELIVERY'
const transfer = await arrangeDispatchDelivery(context, dispatchNo) const transfer = await readAutomaticDispatchDelivery(context, dispatchNo)
check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`) check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`)
const deliveries = transfer.deliveries, outbounds = transfer.outbounds const deliveries = transfer.deliveries, outbounds = transfer.outbounds
check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1 check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1
@@ -538,27 +538,18 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
&& deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1 && deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1
&& deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination') && deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination')
const outboundNo = outbounds[0].outboundNo const outboundNo = outbounds[0].outboundNo
dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo dispatchProof.arrangement = transfer.data?.documents; dispatchProof.outboundNo = outboundNo
dispatchProof.deliveryNo = deliveries[0].deliveryNo dispatchProof.deliveryNo = deliveries[0].deliveryNo
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath)
check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode)
&& detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`)
dispatchProof.action = 'PDA_DEPART' dispatchProof.action = 'VERIFY_AUTOMATIC_DEPARTURE'
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`) check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`)
const departBody = { recordVersion: wireVersion(ready.data.recordVersion), check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`)
idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker } dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING'
const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo
&& departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1
&& departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`)
const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true
&& String(departureReplay.recordVersion) === String(departed.recordVersion)
&& JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result')
dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING'
const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`) const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`)
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId
&& outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED'
@@ -1,4 +1,6 @@
const MODULE = 'procurement-sample-fulfillment' const MODULE = 'procurement-sample-fulfillment'
// Peak cash requirement: 1.00 purchase settled plus 2.00 freight frozen.
export const PROCUREMENT_REQUIRED_AVAILABLE_BALANCE = '3.00'
const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements' const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements'
const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements' const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements'
const clone = value => value == null ? value : structuredClone(value) const clone = value => value == null ? value : structuredClone(value)
@@ -67,7 +69,7 @@ export async function verifyProcurementSample(context, inputs) {
} }
try { try {
proof.walletBefore = clone(await inputs.readWallet()) proof.walletBefore = clone(await inputs.readWallet())
await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= 500n) await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= cents(PROCUREMENT_REQUIRED_AVAILABLE_BALANCE))
const recipient = { name: '采购样品验收', phone: '13800000002', address: '广东省深圳市南山区科技园测试路1号' } const recipient = { name: '采购样品验收', phone: '13800000002', address: '广东省深圳市南山区科技园测试路1号' }
const create = { customerName: inputs.customerName, purchaseCategory: 'SAMPLE', productName: `${context.marker} sample`, productSpecification: 'one-spec', purchaseQuantity: 2, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: recipient.address, contactName: recipient.name, contactPhone: recipient.phone, idempotencyKey: key('create') } const create = { customerName: inputs.customerName, purchaseCategory: 'SAMPLE', productName: `${context.marker} sample`, productSpecification: 'one-spec', purchaseQuantity: 2, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: recipient.address, contactName: recipient.name, contactPhone: recipient.phone, idempotencyKey: key('create') }
const created = await request('POST', MERCHANT, create, '客户创建样品订单', inputs.customerToken) const created = await request('POST', MERCHANT, create, '客户创建样品订单', inputs.customerToken)
@@ -6,9 +6,9 @@ export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({
}) })
export const TAILLEG_ORDER_NUMBER = Object.freeze({ export const TAILLEG_ORDER_NUMBER = Object.freeze({
pattern: /^FL-WH-\d{8}-\d{6}$/, pattern: /^WC\d{8}(?!0000)\d{4}$/,
timeZone: 'server-local', timeZone: 'Asia/Shanghai',
sharedBy: 'containerNo', sharedBy: 'sourceContainerBookingId',
}) })
export function validOwnerSnapshots(orders, expectedCount = orders?.length) { export function validOwnerSnapshots(orders, expectedCount = orders?.length) {
@@ -25,10 +25,10 @@ export function validOwnerSnapshots(orders, expectedCount = orders?.length) {
export function validContainerOrderGroup(rows) { export function validContainerOrderGroup(rows) {
if (!Array.isArray(rows) || rows.length === 0) return false if (!Array.isArray(rows) || rows.length === 0) return false
const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim())) const sourceBookings = new Set(rows.map((row) => String(row?.sourceContainerBookingId ?? '').trim()))
const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim())) const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim()))
const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim()) const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim())
return containerNos.size === 1 && !containerNos.has('') return sourceBookings.size === 1 && !sourceBookings.has('')
&& orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0]) && orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0])
&& sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length && sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length
} }
@@ -62,37 +62,42 @@ test('Lastleg query DTO uses the persisted frozen owner name', () => {
assert.match(orderService, /o\.getCustomerName\(\)/) assert.match(orderService, /o\.getCustomerName\(\)/)
}) })
test('head-leg sourced tail orders preserve the existing container grouping number', () => { test('head-leg sourced tail orders use the registered WC daily sequence', () => {
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), true) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200042'), true)
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false)
assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'containerNo') assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'sourceContainerBookingId')
assert.match(generator, /FL-WH-/) assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai')
assert.match(generator, /BASIC_ISO_DATE/) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200000'), false)
assert.match(generator, /SHA-256/) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), false)
assert.match(generatorTests, /actualContainerNumberRemainsStableAndShared/) assert.match(generator, /case "LASTLEG" -> "WC"/)
assert.match(generator, /return documents\.next\(prefix\)/)
assert.match(generatorTests, /routesEveryOperationalTypeToItsDailySequence/)
}) })
test('one container with 15 inbound rows shares one stable container order number', () => { test('one container with 15 inbound rows shares one stable container order number', () => {
const rows = Array.from({ length: 15 }, (_, index) => ({ const rows = Array.from({ length: 15 }, (_, index) => ({
containerNo: 'CONT-15', orderNo: 'FL-WH-20261020-000042', sourceInboundId: `inbound-${index + 1}`, sourceContainerBookingId: 'booking-15', containerNo: 'CONT-15', orderNo: 'WC202610200042', sourceInboundId: `inbound-${index + 1}`,
})) }))
assert.equal(validContainerOrderGroup(rows), true) assert.equal(validContainerOrderGroup(rows), true)
assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
? { ...row, orderNo: 'FL-WH-20261020-000043' } : row)), false) ? { ...row, orderNo: 'WC202610200043' } : row)), false)
assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
? { ...row, sourceContainerBookingId: 'another-booking' } : row)), false)
assert.equal(validContainerOrderGroup(rows.map((row) => ({ ...row, sourceContainerBookingId: '' }))), false)
assert.match(consumer, /String orderNo\s*=\s*orderNumbers\.findContainerOrderNo\(sourceContainerBookingId\);\s*if \(orderNo == null\) orderNo = orderNoGenerator\.next\("LASTLEG"\);[\s\S]*for \(int index/)
assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/)
assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/) assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/)
}) })
test('same event replay adds nothing and preserves the allocated number', () => { test('same event replay adds nothing and preserves the allocated number', () => {
const before = { orderCount: 15, orderNo: 'FL-WH-20261020-000042', detailCount: 30, historyCount: 15 } const before = { orderCount: 15, orderNo: 'WC202610200042', detailCount: 30, historyCount: 15 }
assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before }), true)
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox='))
assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/) assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/)
assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/) assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/)
assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/) assert.doesNotMatch(duplicateBranch, /createOrder|orderNoGenerator|nextValue/)
const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope')) const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope'))
assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/) assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/)
@@ -79,6 +79,13 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
} }
async function activeSupplier(context, category, suffix) { async function activeSupplier(context, category, suffix) {
if (context.options?.fixedCustomerId) {
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
const supplier = await loadSupplier(context, category, category === 'BOOKING'
? { carrierCode: 'MATSON' } : { truckingScope: 'DOMESTIC' })
if (!supplier) throw new Error(`缺少现有 ${category} 供应商,禁止自动创建`)
return { supplierId: id(supplier.id), supplierName: supplier.name }
}
const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : '' const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : ''
const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`) const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`)
const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE' const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE'
@@ -18,6 +18,11 @@ export function warehouseClaim(token) {
// Called only after the owned actor's inventory operations finish. Credentials // Called only after the owned actor's inventory operations finish. Credentials
// stay in the private handoff context and are never added to createdResources. // stay in the private handoff context and are never added to createdResources.
export async function verifyPdaWarehouseRevocation(context, picker) { export async function verifyPdaWarehouseRevocation(context, picker) {
if (picker?.reused) {
context.addCoverageGap(MODULE, 'fixed-pda-revocation', '固定PDA账号不执行改绑和降权生命周期',
'保留当前账号仓绑定与角色;本轮仅退出测试会话,撤销生命周期未验收', 'not-implemented', { blocking: false })
return
}
if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料') if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料')
const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200') const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200')
const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS) const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS)
@@ -7,11 +7,12 @@ import { fileURLToPath } from 'node:url'
const scriptsDir = dirname(fileURLToPath(import.meta.url)) const scriptsDir = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8')
test('Client full acceptance executes the real notification inbox flow', () => { test('fixed customer acceptance excludes notification writes and reports incomplete coverage', () => {
const isolation = source.indexOf('await isolationModule()') const orchestration = source.match(/try\s*\{\s*await reuseFixedClient\(\)[\s\S]*?\} catch \(error\)/)?.[0] || ''
const notifications = source.indexOf('await notificationInboxModule()') assert.match(orchestration, /'notification-inbox'/)
const security = source.indexOf('await accountSecurityModule()') assert.match(orchestration, /recordSkipped\(module,/)
assert.ok(isolation >= 0 && notifications > isolation && security > notifications) assert.doesNotMatch(orchestration, /await (?:isolationModule|notificationInboxModule|accountSecurityModule)\(/)
assert.match(orchestration, /report\.fullCoverage = !report\.steps\.some\(\(step\) => step\.skipped\)/)
assert.match(source, /async function notificationInboxModule\(\)/) assert.match(source, /async function notificationInboxModule\(\)/)
for (const permission of [ for (const permission of [
'merchant:notification:view', 'merchant:notification:view',
@@ -20,7 +21,7 @@ test('Client full acceptance executes the real notification inbox flow', () => {
]) assert.match(source, new RegExp(`['"]${permission}['"]`)) ]) assert.match(source, new RegExp(`['"]${permission}['"]`))
}) })
test('notification delivery targets only the newly registered customer owner', () => { test('excluded notification lifecycle targets only its fixture customer owner', () => {
assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/) assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/)
assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/) assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/)
assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/) assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/)
+4 -12
View File
@@ -7,7 +7,7 @@ import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-cus
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs'
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' import { pickAll, saveReceiptAndComplete, readAutomaticDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam' const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam'
@@ -486,7 +486,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
&& task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId && task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId
&& row.palletNo === member.palletNo && Number(row.pickedPackages) === 1))) && row.palletNo === member.palletNo && Number(row.pickedPackages) === 1)))
await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements) await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements)
const arranged = await arrangeDispatchDelivery(context, fixture.dispatchNo) const arranged = await readAutomaticDispatchDelivery(context, fixture.dispatchNo)
check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2 check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2
&& arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4) && arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4)
for (const member of members) { for (const member of members) {
@@ -495,16 +495,8 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo }) Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo })
} }
const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken)
check('Arrangement leaves native main ready in AFTER_LOADING', readyTask.status === 'ACTIVE' && readyTask.stage === 'AFTER_LOADING' && readyTask.actions?.DEPART?.allowed === true) check('Automatic departure covers four sources and both customers', readyTask.dispatchNo === fixture.dispatchNo
const departBody = { recordVersion: readyTask.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } && readyTask.status === 'ACTIVE' && readyTask.stage === 'DELIVERING' && Number(readyTask.counts?.shippedPackageCount) === 4)
const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}/depart`
const departed = await request('POST', departPath, departBody, fixture.pdaToken)
check('One native actual departure covers four sources and two customer deliveries', departed.dispatchNo === fixture.dispatchNo
&& departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 4
&& departed.result.deliveries.length === 2)
const departReplay = await request('POST', departPath, departBody, fixture.pdaToken)
check('Departure original-key replay preserves exact version and documents', departReplay.replayed === true
&& id(departReplay.recordVersion) === id(departed.recordVersion) && JSON.stringify(departReplay.result) === JSON.stringify(departed.result))
for (const [index, delivery] of arranged.deliveries.entries()) { for (const [index, delivery] of arranged.deliveries.entries()) {
const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`) const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`)
check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING') check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING')
@@ -295,11 +295,12 @@ test('pool orchestration keeps native one-main boundaries, explicit reviews and
assert.match(source, /actual: false/) assert.match(source, /actual: false/)
assert.match(source, /arranged\.booking\.dispatchNo === fixture\.dispatchNo/) assert.match(source, /arranged\.booking\.dispatchNo === fixture\.dispatchNo/)
assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/)
const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await arrangeDispatchDelivery('), depart = source.indexOf("const departed = await request('POST', departPath") const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await readAutomaticDispatchDelivery('), depart = source.indexOf("const readyTask = await request('GET'")
assert.ok(pick > 0 && pick < load && load < arrange && arrange < depart) assert.ok(pick > 0 && pick < load && load < arrange && arrange < depart)
assert.match(source, /fixture\.loadingMeasurements/) assert.match(source, /fixture\.loadingMeasurements/)
assert.match(source, /recordVersion: current\.recordVersion/) assert.match(source, /recordVersion: current\.recordVersion/)
assert.match(source, /readyTask\.actions\?\.DEPART\?\.allowed === true/) assert.match(source, /readyTask\.stage === 'DELIVERING'/)
assert.doesNotMatch(source, /\/depart`|\/transfer-delivery/)
assert.match(source, /JSON\.stringify\(actual\.fees\) === JSON\.stringify\(signed\.fees\)/) assert.match(source, /JSON\.stringify\(actual\.fees\) === JSON\.stringify\(signed\.fees\)/)
assert.match(source, /sameCustomerOrders\.every\(source => actual\.operations\.filter/) assert.match(source, /sameCustomerOrders\.every\(source => actual\.operations\.filter/)
}) })
+1 -1
View File
@@ -11,7 +11,7 @@
"services": 13 "services": 13
}, },
"openapi": { "openapi": {
"documents": 109, "documents": 111,
"coveredServices": 11, "coveredServices": 11,
"codeFirstServices": 2 "codeFirstServices": 2
}, },
+4 -1
View File
@@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary
const summary = await buildQualitySummary(repositoryRoot) const summary = await buildQualitySummary(repositoryRoot)
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 })
assert.equal(summary.openapi.documents, 109) assert.equal(summary.openapi.documents, 111)
for (const name of ['dispatch-loading-documents', 'dispatch-vehicle-documents']) {
assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`))
}
for (const name of ['cargo-discard-statistics', 'dispatch-transport-tasks', 'fleet-outsourced-business', 'fleet-owned-business', 'order-workflow', 'relabel-quantities', 'return-order-statistics', 'warehouse-transfer-quantities']) { for (const name of ['cargo-discard-statistics', 'dispatch-transport-tasks', 'fleet-outsourced-business', 'fleet-owned-business', 'order-workflow', 'relabel-quantities', 'return-order-statistics', 'warehouse-transfer-quantities']) {
assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`))
} }
+12 -26
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery, verifyDepartureReadiness, import { pickAll, saveReceiptAndComplete, readAutomaticDispatchDelivery, verifyAutomaticDeparture,
dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent, dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent,
relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
@@ -124,7 +124,7 @@ async function ledger(context, scope, dispatchNo) {
return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId), return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId),
beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id)) beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id))
} }
const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture,
saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger } saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger }
/** Continue only a proved native dispatch. All mutations remain real public API commands. */ /** Continue only a proved native dispatch. All mutations remain real public API commands. */
export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) { export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) {
@@ -190,15 +190,10 @@ export async function runRetainedNativeDispatch(context, result, scope, pdaToken
detail = await api('GET', path) detail = await api('GET', path)
} }
requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch') requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch')
const recovered = action => result.requests?.find(row => row.status === 'RESPONDED' && String(row.code) === '200' && row.result?.dispatchNo === dispatchNo && row.result?.action === action)?.result
if (!result.transfer) { if (!result.transfer) {
const saved = recovered('ARRANGE_DELIVERY') requireFact(detail.stage === 'DELIVERING', 'Current dispatch has no verified automatic departure')
if (saved) result.transfer = { data: saved, deliveries: saved.result.deliveries, outbounds: saved.result.outbounds } result.transfer = await deps.readAutomaticDispatchDelivery(context, dispatchNo)
else { requireFact(result.transfer, 'Automatic delivery documents failed validation')
requireFact(detail.stage === 'AFTER_LOADING', 'Missing original arrangement command; do not borrow existing historical delivery rows')
result.transfer = await deps.arrangeDispatchDelivery(context, dispatchNo)
requireFact(result.transfer, 'Native arrangement failed')
}
await context.checkpoint() await context.checkpoint()
} }
const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds
@@ -207,24 +202,15 @@ export async function runRetainedNativeDispatch(context, result, scope, pdaToken
&& row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages, && row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages,
'Native arrangement does not conserve the retained customer and physical quantity') 'Native arrangement does not conserve the retained customer and physical quantity')
result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo) result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo)
if (!result.departure) result.departure = recovered('DEPART') if (!result.departure) {
if (detail.stage === 'AFTER_LOADING') { const verified = await deps.verifyAutomaticDeparture(context, pdaToken, dispatchNo)
const ready = await deps.verifyDepartureReadiness(context, pdaToken, dispatchNo) requireFact(verified, 'Automatic departure has not converged on both endpoints')
requireFact(ready && validVersion(ready.data.recordVersion), 'Native server DEPART gates unavailable') result.departure = verified.data
result.departBody ||= { recordVersion: ready.data.recordVersion, idempotencyKey: `${scope.marker}-DEPART`.slice(0, 128), remark: `${scope.marker}-TEST` }
await context.checkpoint() await context.checkpoint()
result.departure = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken)
const replay = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken)
requireFact(replay.replayed === true && String(replay.recordVersion) === String(result.departure.recordVersion)
&& same(replay.result, result.departure.result), 'Departure original command replay drift')
result.departureReplay = replay
detail = await api('GET', path)
} }
requireFact(result.departure?.stage === 'DELIVERING' && result.departure?.status === 'ACTIVE' requireFact(result.departure?.dispatchNo === dispatchNo && result.departure.stage === 'DELIVERING'
&& Number(result.departure.result?.shippedPackages) === scope.expectedPackages && result.departure.status === 'ACTIVE' && Number(result.departure.counts?.shippedPackageCount) === scope.expectedPackages
&& same(sorted(result.departure.result?.deliveries?.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`) || []), && ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original automatic departure evidence missing or invalid')
sorted(deliveries.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`)))
&& ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original native departure receipt missing or invalid')
result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds) result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds)
result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo) result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo)
requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart) requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart)
@@ -111,10 +111,10 @@ function nativeRunFixture() {
async confirmFleetLoading(_context, _module, _proof, token, measurements) { async confirmFleetLoading(_context, _module, _proof, token, measurements) {
events.push('loading'); assert.equal(token, 'PDA-IN-MEMORY'); assert.equal(measurements, result.loadingMeasurements) events.push('loading'); assert.equal(token, 'PDA-IN-MEMORY'); assert.equal(measurements, result.loadingMeasurements)
if (!measurements) throw Error('Actual palletSpaces measurement is missing') if (!measurements) throw Error('Actual palletSpaces measurement is missing')
stage = 'AFTER_LOADING'; return '9007199254740995' stage = 'DELIVERING'; return '9007199254740995'
}, },
async arrangeDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } }, async readAutomaticDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } },
async verifyDepartureReadiness() { events.push('ready'); return { data: { recordVersion: '9007199254740996' } } }, async verifyAutomaticDeparture() { events.push('ready'); return { data: { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254740996', counts: { shippedPackageCount: 4 } } } },
async saveReceiptAndComplete(current) { await current.request('PUT', DOCS, { expectedVersion: '0', idempotencyKey: 'ORIGINAL-POD' }); return { order: { status: 'DELIVERED' } } }, async saveReceiptAndComplete(current) { await current.request('PUT', DOCS, { expectedVersion: '0', idempotencyKey: 'ORIGINAL-POD' }); return { order: { status: 'DELIVERED' } } },
async finishFleetTrip(_context, _module, proof) { events.push('finish'); proof.closure = { status: 'VERIFIED' }; return proof.closure }, async finishFleetTrip(_context, _module, proof) { events.push('finish'); proof.closure = { status: 'VERIFIED' }; return proof.closure },
async inventorySnapshot() { return stock(picked) }, async inventorySnapshot() { return stock(picked) },
@@ -128,10 +128,10 @@ test('retained native execution preserves real ordering, token/measurement scope
const f = nativeRunFixture() const f = nativeRunFixture()
await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps)
assert.equal(f.result.finalStatus, 'PASSED') assert.equal(f.result.finalStatus, 'PASSED')
assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'depart', 'depart', 'pod', 'finish']) assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'pod', 'finish'])
assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4)
assert.deepEqual(f.result.inventory.ledgerAfter, f.result.inventory.ledgerAfterDepart) assert.deepEqual(f.result.inventory.ledgerAfter, f.result.inventory.ledgerAfterDepart)
assert.equal(f.result.departBody.recordVersion, '9007199254740996') assert.equal(f.result.departure.recordVersion, '9007199254740996')
const originalDepartCount = f.calls.filter(row => row.method === 'POST').length const originalDepartCount = f.calls.filter(row => row.method === 'POST').length
await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps)
assert.equal(f.calls.filter(row => row.method === 'POST').length, originalDepartCount) assert.equal(f.calls.filter(row => row.method === 'POST').length, originalDepartCount)
+6 -2
View File
@@ -4,9 +4,13 @@ import { resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url' import { fileURLToPath, pathToFileURL } from 'node:url'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { verifyProcurementSample } from './admin-test-env-modules/procurement-sample-fulfillment.mjs' import { verifyProcurementSample, PROCUREMENT_REQUIRED_AVAILABLE_BALANCE } from './admin-test-env-modules/procurement-sample-fulfillment.mjs'
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
export function requireExistingProcurementBalance(account) {
if (account?.availableBalance == null || !Number.isFinite(Number(account.availableBalance))
|| Number(account.availableBalance) < Number(PROCUREMENT_REQUIRED_AVAILABLE_BALANCE)) throw new Error(`采购样本需要至少 ${PROCUREMENT_REQUIRED_AVAILABLE_BALANCE} 元现有可用余额;禁止自动充值`)
}
export function options(args) { export function options(args) {
const value = name => { const index = args.indexOf(name); return index < 0 ? '' : args[index + 1] || '' } const value = name => { const index = args.indexOf(name); return index < 0 ? '' : args[index + 1] || '' }
if (!args.includes('--execute')) return { execute: false } if (!args.includes('--execute')) return { execute: false }
@@ -89,7 +93,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e
return accountForCustomer(page, customerId) return accountForCustomer(page, customerId)
} }
const before = await readWallet() const before = await readWallet()
requireSuccess(await context.request('POST',`/api/platform/finance/v1/accounts/${customerId}/adjustments`,{ type:'RECHARGE', amount:'10.00', occurredDate:new Date().toISOString().slice(0,10), paymentMethod:'BANK_TRANSFER', referenceNo:context.marker, remark:context.marker, expectedAccountVersion:before.accountVersion },context.token,{'Idempotency-Key':`${context.marker}-fund`})) requireExistingProcurementBalance(before)
const upload = async (orderNo,category) => requireSuccess(await context.requestMultipart('POST',`/api/platform/v1/last-leg/overseas-procurements/${encodeURIComponent(orderNo)}/files/${category}?customerId=${customerId}`,{ content:PNG,type:'image/png',name:`${category.toLowerCase()}.png` })) const upload = async (orderNo,category) => requireSuccess(await context.requestMultipart('POST',`/api/platform/v1/last-leg/overseas-procurements/${encodeURIComponent(orderNo)}/files/${category}?customerId=${customerId}`,{ content:PNG,type:'image/png',name:`${category.toLowerCase()}.png` }))
report.status = 'RUNNING'; await checkpoint() report.status = 'RUNNING'; await checkpoint()
await verifyProcurementSample(context,{ marker:context.marker,customerId,customerName:fixture.tenantName,customerToken,warehouseId:config.warehouseId,readWallet,upload }) await verifyProcurementSample(context,{ marker:context.marker,customerId,customerName:fixture.tenantName,customerToken,warehouseId:config.warehouseId,readWallet,upload })
+8 -1
View File
@@ -1,6 +1,13 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { execute, options, accountForCustomer } from './procurement-sample-test-env.mjs' import { execute, options, accountForCustomer, requireExistingProcurementBalance } from './procurement-sample-test-env.mjs'
test('procurement uses available existing funds without funding the account', () => {
requireExistingProcurementBalance({ availableBalance: '3.00' })
for (const value of [undefined, null, 'invalid', '2.99', '-1']) {
assert.throws(() => requireExistingProcurementBalance({ actualBalance: '100', availableBalance: value }), /禁止自动充值/)
}
})
test('default preview needs no credentials and performs no execution', async () => { test('default preview needs no credentials and performs no execution', async () => {
const result = await execute([], []) const result = await execute([], [])