fix(loading): remove ISO container validation across PDA fulfillment

This commit is contained in:
2026-10-23 11:12:36 +08:00
parent c38f1758c5
commit 9c19980736
22 changed files with 215 additions and 162 deletions
@@ -2,15 +2,11 @@ package com.baiyue.common.event;
import java.util.Locale; import java.util.Locale;
/** Canonical physical container number used by cross-region business facts. */ /** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
public final class ContainerNumbers { public final class ContainerNumbers {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private ContainerNumbers() { } private ContainerNumbers() { }
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
public static String normalizeRequired(String value) { public static String normalizeRequired(String value) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new IllegalArgumentException("containerNo is required"); throw new IllegalArgumentException("containerNo is required");
@@ -19,21 +15,13 @@ public final class ContainerNumbers {
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint) value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint); && !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
String normalized = compact.toString().toUpperCase(Locale.ROOT); String normalized = compact.toString().toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) { if (normalized.isEmpty()) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346"); throw new IllegalArgumentException("containerNo is required");
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = normalized.charAt(index);
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
} }
return normalized; return normalized;
} }
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
public static String requireCanonical(String value) { public static String requireCanonical(String value) {
String normalized = normalizeRequired(value); String normalized = normalizeRequired(value);
if (!normalized.equals(value)) { if (!normalized.equals(value)) {
@@ -1,20 +1,41 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerNumbersTest { class ContainerNumbersTest {
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
@Test @Test
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() { void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970"); assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970")) assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
.hasMessageContaining("normalized"); .hasMessageContaining("normalized");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971")) assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
.hasMessageContaining("check digit"); .hasMessageContaining("normalized");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001")) }
.hasMessageContaining("ISO 6346");
/** 非 ISO 柜号可以作为事件事实,不在下游重新阻断。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "ZCSU7881971", "MSCA6639870", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void acceptsNonIsoNumber(String containerNo) {
assertThat(ContainerNumbers.normalizeRequired(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
.isEqualTo(containerNo);
assertThat(ContainerNumbers.requireCanonical(containerNo)).isEqualTo(containerNo);
}
/** 规范化前后均不得将空白柜号作为业务事实。 */
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\u00a0\u3000"})
void rejectsMissingNumber(String containerNo) {
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired(containerNo))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(containerNo))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
} }
} }
+1 -1
View File
@@ -94,7 +94,7 @@
## 国内货柜履约装柜与 PDA 资源结算 ## 国内货柜履约装柜与 PDA 资源结算
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;该关联入口仅规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。关联已接受的柜号在运输信息未改号时不重复做 ISO 校验;确认预装与创建 PDA 任务前必须校验 ISO 格式并提示在预装运输信息中纠错,避免实物作业后无法完成,PDA 完成及跨服务事件保持原有格式校验;运输或提柜首次录入及纠正为不同柜号保留原有规则。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;关联、运输修改和提柜补录统一只规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。确认预装、创建 PDA 任务和 PDA 完成只要求真实柜号非空,不再校验 ISO 格式;跨服务装柜、船节点和卸柜事件统一接受非 ISO 柜号,冻结事实仍要求柜号已规范化且与来源柜一致。运输纠错继续遵守原因、版本及 PDA 领取和物理作业冻结限制。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 - 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
@@ -1,49 +0,0 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
public final class Iso6346ContainerNumber {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private Iso6346ContainerNumber() {
}
public static String require(String raw) {
String value = normalizeWhitespace(raw);
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw invalid();
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = value.charAt(index);
int number = Character.isDigit(current)
? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
int expected = sum % 11 % 10;
if (value.charAt(10) - '0' != expected) throw invalid();
return value;
}
private static String normalizeWhitespace(String raw) {
if (raw == null) return null;
StringBuilder value = new StringBuilder(raw.length());
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint))
.forEach(value::appendCodePoint);
String normalized = value.toString().toUpperCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static BizException invalid() {
return new BizException(ResultCode.BAD_REQUEST,
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
/** 真实集装箱号录入与作业门禁,与跨服务事件使用同一规范化规则。 */
public final class RealContainerNumber {
private RealContainerNumber() { }
/** 保留柜号必填要求,不再检查 ISO 长度、设备类别和校验位。 */
public static String require(String raw) {
try {
return ContainerNumbers.normalizeRequired(raw);
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "真实集装箱号不能为空");
}
}
}
@@ -12,7 +12,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.domain.RealContainerNumber;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
@@ -124,6 +124,7 @@ public class ContainerService {
return required(id); return required(id);
} }
/** 提柜后补录规范化真实柜号,保留同订舱唯一及作业冻结门禁。 */
@Transactional @Transactional
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion, public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -138,7 +139,7 @@ public class ContainerService {
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) { || repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号"); throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
} }
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo); String containerNo = RealContainerNumber.require(requestedContainerNo);
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) { if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
throw conflict("当前提单下集装箱号已存在"); throw conflict("当前提单下集装箱号已存在");
} }
@@ -26,7 +26,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.domain.RealContainerNumber;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog; import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -493,15 +493,10 @@ public class LoadingListService {
return repository.preloadDetail(requirePlan(planId)); return repository.preloadDetail(requirePlan(planId));
} }
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */ /** 未提交柜号时保留原值,首次录入或纠错统一规范化并禁止清空。 */
private static String targetContainerNo(AggregateRow before, String requested) { private static String targetContainerNo(AggregateRow before, String requested) {
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null; if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "") return RealContainerNumber.require(requested);
.toUpperCase(java.util.Locale.ROOT);
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
return before.getContainerNo();
}
return Iso6346ContainerNumber.require(requested);
} }
private static String transportHash(long planId, int version, String containerNo, private static String transportHash(long planId, int version, String containerNo,
@@ -23,7 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit; import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.domain.RealContainerNumber;
import com.baiyue.firstleg.client.DomesticPdaOperatorClient; import com.baiyue.firstleg.client.DomesticPdaOperatorClient;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
@@ -140,7 +140,7 @@ public class LoadingPickService {
smartLoadingRepository.items(plan.getId())); smartLoadingRepository.items(plan.getId()));
} }
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */ /** 确认预装前验证作业资格和真实柜号必填。 */
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) { public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId); inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId); List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
@@ -614,12 +614,12 @@ public class LoadingPickService {
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */ /** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
public record CompletionAccepted(long commandId, long acceptedAtNanos) {} public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
/** 在仍可纠错的预装阶段提示格式错误,避免领取或实物作业后卡在完成门禁。 */ /** 确认预装和创建 PDA 任务前要求柜号非空,不以 ISO 格式阻断作业。 */
private static void requireContainerNumberBeforeStarting(String containerNo) { private static void requireContainerNumberBeforeStarting(String containerNo) {
try { try {
Iso6346ContainerNumber.require(containerNo); RealContainerNumber.require(containerNo);
} catch (BizException exception) { } catch (BizException exception) {
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346"); throw conflict("请先在预装运输信息中录入真实集装箱号后再确认预装或启动PDA");
} }
} }
@@ -630,9 +630,9 @@ public class LoadingPickService {
throw conflict("PDA确认装柜完成前必须录入真实集装箱号"); throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
} }
try { try {
Iso6346ContainerNumber.require(booking.getContainerNo()); RealContainerNumber.require(booking.getContainerNo());
} catch (BizException exception) { } catch (BizException exception) {
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号"); throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
} }
} }
@@ -607,7 +607,7 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link: /api/platform/v1/containers/{id}/ocean-booking-link:
post: post:
summary: 将内部模拟柜关联到已发布订舱需求 summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。关联接受的柜号在运输信息未修改柜号时不重复执行ISO校验;确认预装和创建PDA任务前仍校验ISO格式,并提示在预装运输信息中纠错,防止实物作业后才发现无法完成。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。 description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式;确认预装及创建PDA任务前仍必须补齐真实柜号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}} responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -2947,7 +2947,7 @@ components:
properties: properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID} oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID} requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前需纠正为符合ISO格式的真实柜号} containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前必须补齐真实柜号,不校验ISO格式}
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest: ContainerFeeAdjustRequest:
type: object type: object
@@ -50,7 +50,7 @@ paths:
summary: 修改预装运输信息 summary: 修改预装运输信息
description: >- description: >-
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; 可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 或规范化后等于当前值时保留关联入口已接受的柜号,不重复校验格式;首次录入或纠正为不同值时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 containerNo 为 null 时保留当前柜号;首次录入或纠正时服务端移除格式空白并转大写,不校验 ISO 6346 长度、结构、设备类别或校验位,空白字符串非法且禁止清空;同一订舱内柜号必须唯一。
x-permission: platform:firstleg:preload:edit x-permission: platform:firstleg:preload:edit
parameters: parameters:
- {$ref: '#/components/parameters/PlanId'} - {$ref: '#/components/parameters/PlanId'}
@@ -107,7 +107,7 @@ class ContainerControllerContractTest {
.contains("containerNo 可空") .contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号") .contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一") .contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
.contains("确认预装和创建PDA任务前仍校验ISO格式") .contains("运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式")
.doesNotContain("/api/platform/v1/containers/{id}/transport:") .doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest") .doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest") .doesNotContain("ContainerTransportUpdateRequest")
@@ -73,7 +73,7 @@ class LoadingListControllerContractTest {
.contains("containerNo") .contains("containerNo")
.contains("voyageNo") .contains("voyageNo")
.contains("首次补录真实集装箱号") .contains("首次补录真实集装箱号")
.contains("校验ISO 6346结构、设备类别和校验位") .contains("不校验 ISO 6346 长度、结构、设备类别或校验位")
.contains("PDA任务尚未领取且无任何物理作业事实") .contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm") .contains("/loading-plans/{planId}/confirm")
.contains("/loading-plans/{planId}/lifecycle") .contains("/loading-plans/{planId}/lifecycle")
@@ -1,23 +0,0 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Iso6346ContainerNumberTest {
@Test
void normalizesAllowedWhitespaceAndLowercase() {
assertThat(Iso6346ContainerNumber.require(" ms cu 663987 0 "))
.isEqualTo("MSCU6639870");
}
@Test
void rejectsWrongEquipmentCategoryAndCheckDigit() {
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCA6639870"))
.isInstanceOf(BizException.class).hasMessageContaining("设备类别");
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCU6639871"))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RealContainerNumberTest {
/** 录入只去空白转大写,ISO 长度、设备类别与校验位不构成门禁。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCA6639870", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void normalizesWithoutIsoValidation(String containerNo) {
assertThat(RealContainerNumber.require(" \u00a0" + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
.isEqualTo(containerNo);
}
/** 空柜号不能进入实际作业,包含规范化后为空的 Unicode 空白。 */
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\u00a0\u3000"})
void rejectsMissingNumber(String containerNo) {
assertThatThrownBy(() -> RealContainerNumber.require(containerNo))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
}
}
@@ -297,21 +297,23 @@ class ContainerServiceTest {
verifyNoInteractions(oceanBookings); verifyNoInteractions(oceanBookings);
} }
@Test /** 提柜补录使用相同的非 ISO 规则,并按版本原子保存。 */
@ParameterizedTest
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
@DisplayName("提柜后按柜次版本原子补录真实柜号") @DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() { void assignsRealContainerNumberAfterPickup(String containerNo) {
ContainerBookingEntity linked = booking("EDITING", 3); ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L); linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
ContainerBookingEntity assigned = booking("EDITING", 4); ContainerBookingEntity assigned = booking("EDITING", 4);
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L); assigned.setContainerNo(containerNo); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked, assigned); when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1); when(repository.assignRealContainerNo(11L, containerNo, 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo( ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin"); 11L, " " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870"); assertThat(result.getContainerNo()).isEqualTo(containerNo);
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin"); verify(repository).assignRealContainerNo(11L, containerNo, 3, 9L, "admin");
} }
@Test @Test
@@ -101,6 +101,23 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.eventVersion()).isEqualTo(3); assertThat(first.eventVersion()).isEqualTo(3);
} }
/** 非 ISO 柜号能生成当前 V3 事件,并保持冻结柜号原值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
void publishesNonIsoContainerNumber(String containerNo) throws Exception {
Row value = row(11, 100, 101);
value.setContainerNo(containerNo);
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captured = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captured.capture());
EventEnvelope event = mapper.readValue(captured.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.eventVersion()).isEqualTo(3);
assertThat(event.payload().path("containerNo").asText()).isEqualTo(containerNo);
}
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() { @Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
Row value = row(11, 100, 101); value.setContainerNo(" "); Row value = row(11, 100, 101); value.setContainerNo(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value)); when(snapshots.rows(90L)).thenReturn(List.of(value));
@@ -168,17 +168,20 @@ class LoadingListServiceTest {
verify(loadingPickService).beginIfReady(30L, 9L, "operator"); verify(loadingPickService).beginIfReady(30L, 9L, "operator");
} }
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */ /** 运输纠错与关联入口一致,只规范化柜号并保留纠错原因。 */
@Test @ParameterizedTest
void stillRejectsNewNonIsoNumberInTransportCorrection() { @ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void acceptsNonIsoNumberInTransportCorrection(String containerNo) {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1"); before.setContainerNo("LOCAL-1");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(repository.updateTransport(10L, 3, containerNo, null, 9L, "operator")).thenReturn(1);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction", service.updateTransport(10L, 100L, 9L, "operator", "non-iso-correction",
new TransportUpdateRequest("OTHER-2", null, 3, "纠错"))) new TransportUpdateRequest(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", null, 3, "纠错"));
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); verify(repository).updateTransport(10L, 3, containerNo, null, 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
} }
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */ /** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
@@ -235,7 +238,7 @@ class LoadingListServiceTest {
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" ", "023E", 3, "清空测试"))) new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346"); .isInstanceOf(BizException.class).hasMessageContaining("不能为空");
verify(repository, never()).updateTransport(eq(10L), eq(3), verify(repository, never()).updateTransport(eq(10L), eq(3),
org.mockito.ArgumentMatchers.nullable(String.class), org.mockito.ArgumentMatchers.nullable(String.class),
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L), org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
@@ -962,10 +962,10 @@ class LoadingPickServiceTest {
verify(repository, never()).acceptCompletion(anyLong(), anyInt()); verify(repository, never()).acceptCompletion(anyLong(), anyInt());
} }
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */ /** 关联接受的非 ISO 柜号可直接确认预装,保留订舱放舱门禁。 */
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"}) @ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) { void acceptsNonIsoNumberBeforeConfirmation(String containerNo) {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, containerNo, "EDITING", 3, 2, null); 10L, 40L, containerNo, "EDITING", 3, 2, null);
var booking = new ContainerBookingEntity(); var booking = new ContainerBookingEntity();
@@ -974,12 +974,6 @@ class LoadingPickServiceTest {
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking); when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(completionCommands, never()).insert(any());
booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity(); var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); var ocean = new OceanBookingEntity();
@@ -990,27 +984,58 @@ class LoadingPickServiceTest {
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
} }
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */ /** 非 ISO 柜号可以创建 PDA 任务,任务仍按柜次关联权威柜号。 */
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"}) @ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) { void createsPdaTaskWithNonIsoNumber(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of())) var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息"); plan.setId(10L); plan.setContainerBookingId(40L);
verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); var booking = new ContainerBookingEntity();
verify(repository, never()).insert(any(LoadingPickItemEntity.class)); booking.setId(40L); booking.setContainerNo(containerNo); booking.setCabinetNo("D2092");
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
doAnswer(invocation -> {
LoadingPickTaskEntity created = invocation.getArgument(0);
created.setId(20L);
return 1;
}).when(repository).insert(any(LoadingPickTaskEntity.class));
service.begin(10L, containerNo, 9L, "operator", List.of());
ArgumentCaptor<LoadingPickTaskEntity> captured = ArgumentCaptor.forClass(LoadingPickTaskEntity.class);
verify(repository).insert(captured.capture());
assertThat(captured.getValue().getContainerBookingId()).isEqualTo(40L);
assertThat(captured.getValue().getTaskStatus()).isEqualTo("LOADING");
} }
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */ /** 去除 ISO 校验不应放开缺号任务。 */
@Test @ParameterizedTest
void completionStillRequiresIsoNumberForCrossServiceFacts() { @org.junit.jupiter.params.provider.NullAndEmptySource
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871"); @ValueSource(strings = {" ", "\u00a0\u3000"})
when(repository.taskForUpdate(20L)).thenReturn(task()); void rejectsMissingNumberBeforeCreatingPdaTask(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
.isInstanceOf(BizException.class).hasMessageContaining("录入真实集装箱号");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
}
assertThatThrownBy(() -> service.complete(20L, /** 非 ISO 柜号不会在完成阶段再次受阻,仍通过持久化命令结算。 */
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator")) @ParameterizedTest
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346"); @ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
verify(completionCommands, never()).insert(any()); void completionAcceptsNonIsoNumberForCrossServiceFacts(String containerNo) {
verify(repository, never()).acceptCompletion(anyLong(), anyInt()); repository.activeBookingByTask(20L).setContainerNo(containerNo);
when(repository.taskForUpdate(20L)).thenReturn(task());
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
completing.setVersion(1);
when(repository.task(20L)).thenReturn(completing);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
Task result = service.complete(20L,
new CompleteLoadingRequest(0, "complete-non-iso", "checked"), 9L, "operator");
assertThat(result.taskStatus()).isEqualTo("COMPLETING");
verify(completionCommands).insert(any());
verify(repository).acceptCompletion(20L, 0);
} }
@Test @Test
@@ -3624,7 +3624,7 @@ components:
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady] required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady]
properties: properties:
sourceContainerBookingId: { type: string } sourceContainerBookingId: { type: string }
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 } containerNo: { type: string, minLength: 1, description: 装柜完成事件中非空且已移除空白并转大写的真实集装箱号,不校验 ISO 6346 长度、结构、设备类别或校验位;不使用 cabinetNo、订单号或内部 ID 回退 }
overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 } overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 } overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 }
overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 } overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 }
@@ -281,6 +281,28 @@ class LoadingCompletedConsumerTest {
assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet())); assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet()));
assertTrue(captured.getAllValues().stream().allMatch(value->"book-1".equals(value.getSourceContainerBookingId()))); assertTrue(captured.getAllValues().stream().allMatch(value->"book-1".equals(value.getSourceContainerBookingId())));
} }
/** 当前 V3 事件接受非 ISO 柜号,海外订单和来源柜快照保持同一实箱值。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
void consumesV3EventWithNonIsoContainerNumber(String containerNo) {
EventEnvelope source=event();
var node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source);
node.put("containerNo",containerNo);
var values=node.putArray("loadingEvidence"); int fileId=10;
for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"))
values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg");
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),
source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),
source.causationId(),node,hash);
assertEquals(DeliveryAck.Outcome.APPLIED,service.consume(event).outcome());
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(2)).insert(captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(order->containerNo.equals(order.getContainerNo())));
assertEquals(containerNo,frozenContainerNos.get("book-1"));
}
@Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){ @Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){
EventEnvelope source=event(); JsonNode node=v2Payload(source); EventEnvelope source=event(); JsonNode node=v2Payload(source);
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
+2
View File
@@ -451,6 +451,8 @@ plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当
`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 `phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。
`phase3-container-loading` 使用非 ISO 柜号 `JZXH10231043` 验证关联与预装确认,PDA 领取前再纠正为本轮 `TEST-…-NOISO` 柜号,继续完成拣货、五类合成装柜证据及装柜完成,保留空号拒绝和作业后禁改校验;不修改既有实际作业单。跨服务接收须按本轮来源柜及最终柜号另行回查,不能仅凭 PDA 完成视为海外消费通过。
部署后需要定向验证该完整链时,模块顺序固定为: 部署后需要定向验证该完整链时,模块顺序固定为:
```powershell ```powershell
@@ -175,7 +175,8 @@ export async function run(context) {
if (!selectedChannel) return if (!selectedChannel) return
const channelName = selectedChannel.channelName const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 } const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 }
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) // 使用曾在推送 PDA 时被 ISO 规则拒绝的柜号,覆盖关联到确认的真实链路。
const realContainerNo = 'JZXH10231043'
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
context.assert(MODULE, '放舱订舱使用真实启用供应商', null, context.assert(MODULE, '放舱订舱使用真实启用供应商', null,
@@ -638,7 +639,8 @@ export async function run(context) {
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照', const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
beforeReplacementSnapshotResponse, '', OPTIONS) beforeReplacementSnapshotResponse, '', OPTIONS)
const replacementContainerNo = iso6346ContainerNumber(`${context.marker}-replacement`) // 纠错后仍保留非 ISO 格式,确保完成及跨服务事件不会重新加回校验。
const replacementContainerNo = `TEST-${context.marker}-NOISO`.slice(0, 64)
const replacementResponse = await request(context, 'PATCH', const replacementResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),