Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27881808d | ||
|
|
ba0bf9ebb7 | ||
|
|
8d211acf5b | ||
|
|
5e9cebcf85 | ||
|
|
0fe1d10566 | ||
|
|
50687c851d | ||
|
|
2ae8e7ce94 | ||
|
|
e31dcf80f9 | ||
|
|
2573bd92cb | ||
|
|
a8192f51fe | ||
|
|
20b2a7e058 | ||
|
|
7ef8411ea4 | ||
|
|
ab70c63988 | ||
|
|
ecc76d4f91 | ||
|
|
9c19980736 | ||
|
|
c38f1758c5 |
@@ -2,15 +2,11 @@ package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
/** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
|
||||
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() { }
|
||||
|
||||
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
@@ -19,21 +15,13 @@ public final class ContainerNumbers {
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
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");
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
|
||||
+27
-6
@@ -1,20 +1,41 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
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.assertThatThrownBy;
|
||||
|
||||
class ContainerNumbersTest {
|
||||
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
|
||||
@Test
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||
.hasMessageContaining("check digit");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||
.hasMessageContaining("ISO 6346");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
/** 非 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ CREATE TABLE ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联允许真实集装箱号暂缺;确认预装和创建 PDA task 前必须补齐非空柜号,不按 ISO 格式阻断;可在 `EDITING/LOADING` 首次补录,PDA 完成前再次校验;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
|
||||
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||
@@ -58,7 +58,7 @@
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单号或已有非终态预约的订舱;创建及派车不要求订舱文件;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
@@ -79,7 +79,7 @@
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `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` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
## 国内货柜履约装柜与 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 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`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`。
|
||||
|
||||
@@ -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, "真实集装箱号不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import jakarta.validation.constraints.*; import java.math.BigDecimal; import jav
|
||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
||||
/** 已放舱订舱候选,预约不依赖订舱附件。 */
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
||||
/** 查询已放舱且有提单号的可预约订舱,不依赖附件。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
|
||||
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
|
||||
o.channel_name channelName,TRUE bookingDocumentReady
|
||||
o.channel_name channelName
|
||||
FROM fl_ocean_booking o
|
||||
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
|
||||
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||
WHERE o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
||||
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
|
||||
+1
-2
@@ -51,7 +51,6 @@ public class OceanBookingService {
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Resource private WarehousePartnerClient partners;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OceanBookingDocumentService documents;
|
||||
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
@@ -194,6 +193,7 @@ public class OceanBookingService {
|
||||
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
|
||||
}
|
||||
|
||||
/** 校验完整订舱资料后放舱,不以附件上传作为前置条件。 */
|
||||
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
|
||||
String action = "RELEASE";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
@@ -202,7 +202,6 @@ public class OceanBookingService {
|
||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||
validateRelease(before, repository.parties(id), repository.requirements(id));
|
||||
validateMasterData(before);
|
||||
documents.requireActiveValidDocument(before);
|
||||
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
|
||||
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
|
||||
@@ -383,11 +383,12 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
/** 确认费用时保留物流主状态,一件代发待组托也允许确认。 */
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus,
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
List.of("FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
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.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
@@ -124,6 +124,7 @@ public class ContainerService {
|
||||
return required(id);
|
||||
}
|
||||
|
||||
/** 提柜后补录规范化真实柜号,保留同订舱唯一及作业冻结门禁。 */
|
||||
@Transactional
|
||||
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
@@ -138,7 +139,7 @@ public class ContainerService {
|
||||
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
|
||||
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
||||
}
|
||||
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
String containerNo = RealContainerNumber.require(requestedContainerNo);
|
||||
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
|
||||
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.LoadingPickRepository;
|
||||
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.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -493,15 +493,10 @@ public class LoadingListService {
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */
|
||||
/** 未提交柜号时保留原值,首次录入或纠错统一规范化并禁止清空。 */
|
||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
|
||||
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT);
|
||||
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
|
||||
return before.getContainerNo();
|
||||
}
|
||||
return Iso6346ContainerNumber.require(requested);
|
||||
return RealContainerNumber.require(requested);
|
||||
}
|
||||
|
||||
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.InventoryUnit;
|
||||
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.entity.LoadingPickAssignmentEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
@@ -140,7 +140,7 @@ public class LoadingPickService {
|
||||
smartLoadingRepository.items(plan.getId()));
|
||||
}
|
||||
|
||||
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */
|
||||
/** 确认预装前验证作业资格和真实柜号必填。 */
|
||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||
@@ -614,12 +614,12 @@ public class LoadingPickService {
|
||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
||||
|
||||
/** 在仍可纠错的预装阶段提示格式错误,避免领取或实物作业后卡在完成门禁。 */
|
||||
/** 确认预装和创建 PDA 任务前要求柜号非空,不以 ISO 格式阻断作业。 */
|
||||
private static void requireContainerNumberBeforeStarting(String containerNo) {
|
||||
try {
|
||||
Iso6346ContainerNumber.require(containerNo);
|
||||
RealContainerNumber.require(containerNo);
|
||||
} catch (BizException exception) {
|
||||
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346");
|
||||
throw conflict("请先在预装运输信息中录入真实集装箱号后再确认预装或启动PDA");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,9 +630,9 @@ public class LoadingPickService {
|
||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||
}
|
||||
try {
|
||||
Iso6346ContainerNumber.require(booking.getContainerNo());
|
||||
RealContainerNumber.require(booking.getContainerNo());
|
||||
} catch (BizException exception) {
|
||||
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
|
||||
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -36,7 +35,6 @@ public class TruckAppointmentService {
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource TruckAppointmentCompletionService completion;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@Resource TruckAppointmentFileClient files;
|
||||
@Resource WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@@ -64,13 +62,13 @@ public class TruckAppointmentService {
|
||||
return completeOperation(id,action,idem,enrich(required(id)));
|
||||
}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
||||
/** 从已放舱订舱校验仓库和提单号,附件不作为预约条件。 */
|
||||
private AssociationContext validateAssociations(SaveRequest r){
|
||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
|
||||
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
|
||||
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
||||
}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
||||
@@ -85,13 +83,13 @@ public class TruckAppointmentService {
|
||||
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
||||
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
|
||||
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
|
||||
/** 派车时复核订舱放舱状态及提单号,不要求订舱附件。 */
|
||||
private String requireDispatchReady(TruckAppointmentEntity appointment){
|
||||
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
|
||||
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
|
||||
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return bill;
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
|
||||
+6
-4
@@ -9,11 +9,12 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCalendarSourceMapper {
|
||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||
/** 按明确来源柜读取到港事实;目的港优先显示名称,未填名称时显示已保存代码。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
@@ -28,10 +29,11 @@ public interface ContainerCalendarSourceMapper {
|
||||
""")
|
||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||
/** 选出到港、缺日期和可能还柜的有效出仓柜;目的港名称为空时展示代码,最终日期由服务复核。 */
|
||||
@Select("""
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,
|
||||
COALESCE(NULLIF(TRIM(c.destination_port_name),''),NULLIF(TRIM(c.destination_port_code),'')) destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
containerNo: {type: string, nullable: true}
|
||||
vesselName: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
destinationPortName: {type: string, nullable: true, description: 目的港展示值,优先名称,名称为空时使用已保存的港口代码,两者均为空时返回null}
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
arrivalAt: {type: string, format: date-time, nullable: true}
|
||||
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
|
||||
|
||||
@@ -607,7 +607,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
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'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
@@ -2947,7 +2947,7 @@ components:
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱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}
|
||||
ContainerFeeAdjustRequest:
|
||||
type: object
|
||||
|
||||
@@ -50,7 +50,7 @@ paths:
|
||||
summary: 修改预装运输信息
|
||||
description: >-
|
||||
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
containerNo 为 null 或规范化后等于当前值时保留关联入口已接受的柜号,不重复校验格式;首次录入或纠正为不同值时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
containerNo 为 null 时保留当前柜号;首次录入或纠正时服务端移除格式空白并转大写,不校验 ISO 6346 长度、结构、设备类别或校验位,空白字符串非法且禁止清空;同一订舱内柜号必须唯一。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking API
|
||||
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 transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. 放舱不要求上传订舱确认文件或提单文件。 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:
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
@@ -48,7 +48,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
description: 放舱校验已保存的订舱业务资料,不以订舱确认文件或提单文件作为前置条件。 The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Truck Appointment API
|
||||
version: 2.0.0
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking. 预约创建及派车不要求订舱附件。 Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||
paths:
|
||||
/api/platform/v1/truck-appointments:
|
||||
get:
|
||||
@@ -33,7 +33,7 @@ paths:
|
||||
/api/platform/v1/truck-appointments/candidates:
|
||||
get:
|
||||
summary: Query authoritative create-time ocean-booking candidates
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading number, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
parameters:
|
||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
@@ -168,7 +168,7 @@ components:
|
||||
timestamp: {type: string, format: date-time}
|
||||
Candidate:
|
||||
type: object
|
||||
required: [oceanBookingId, oceanBookingNo, warehouseId, bookingDocumentReady]
|
||||
required: [oceanBookingId, oceanBookingNo, warehouseId]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64}
|
||||
oceanBookingNo: {type: string}
|
||||
@@ -177,7 +177,7 @@ components:
|
||||
warehouseName: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
bookingDocumentReady: {type: boolean, enum: [true]}
|
||||
|
||||
DetailEnvelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
@@ -221,7 +221,7 @@ components:
|
||||
required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]
|
||||
properties:
|
||||
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading number. 订舱附件不作为前置条件。}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
|
||||
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
|
||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ class ContainerControllerContractTest {
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
|
||||
.contains("确认预装和创建PDA任务前仍校验ISO格式")
|
||||
.contains("运输信息、提柜补录、确认预装、创建PDA任务、装柜完成及跨服务事件统一不校验ISO格式")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ class LoadingListControllerContractTest {
|
||||
.contains("containerNo")
|
||||
.contains("voyageNo")
|
||||
.contains("首次补录真实集装箱号")
|
||||
.contains("校验ISO 6346结构、设备类别和校验位")
|
||||
.contains("不校验 ISO 6346 长度、结构、设备类别或校验位")
|
||||
.contains("PDA任务尚未领取且无任何物理作业事实")
|
||||
.contains("/loading-plans/{planId}/confirm")
|
||||
.contains("/loading-plans/{planId}/lifecycle")
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest {
|
||||
}
|
||||
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
|
||||
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","database booking-level unique key");}
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB]","default: RMB").doesNotContain("receivableAmount");}
|
||||
|
||||
@@ -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("不能为空");
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -118,7 +118,7 @@ class OceanBookingServiceTest {
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getCustomerId()).isNull();
|
||||
assertThat(result.oceanBooking().getCustomerName()).isNull();
|
||||
verify(documents).requireActiveValidDocument(stored.get());
|
||||
verifyNoInteractions(documents);
|
||||
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
|
||||
}
|
||||
|
||||
@@ -188,21 +188,22 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
/** 无订舱附件时仍可按完整业务资料放舱。 */
|
||||
@Test
|
||||
void rejectsReleaseWithoutActiveBookingOwnedFileWithoutStateDrift() {
|
||||
void releasesWithoutBookingFiles() {
|
||||
stubMasterData();
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
when(repository.find(1L)).thenReturn(draft, completeBooking("RELEASED", 4));
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(documents).requireActiveValidDocument(draft);
|
||||
when(repository.documents(1L)).thenReturn(List.of());
|
||||
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-file",
|
||||
new ReleaseRequest(3, "确认")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
Detail result = service.release(1L, 9L, "admin", "release-no-file", new ReleaseRequest(3, "确认"));
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.documents()).isEmpty();
|
||||
verifyNoInteractions(documents);
|
||||
verify(repository).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 通过真实 Mapper 执行费用状态更新,防止业务层放行但持久化条件遗漏作业状态。 */
|
||||
class FbaPricingFeeTransitionTest {
|
||||
private SqlSession session;
|
||||
private JdbcTemplate sql;
|
||||
private FbaPricingRepository repository;
|
||||
|
||||
/** 每项测试使用独立内存表,保留真实的客户和逻辑删除条件。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:fee-transition-" + UUID.randomUUID() + ";MODE=MySQL");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(InboundPricingMapper.class);
|
||||
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
|
||||
session.getConnection();
|
||||
sql = new JdbcTemplate(ds);
|
||||
sql.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT," +
|
||||
"process_status VARCHAR(32),fee_status VARCHAR(32),deleted BOOLEAN DEFAULT FALSE)");
|
||||
repository = new FbaPricingRepository();
|
||||
ReflectionTestUtils.setField(repository, "inbounds", session.getMapper(InboundPricingMapper.class));
|
||||
}
|
||||
|
||||
/** 关闭最后一个连接后销毁本项测试的内存数据库。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (session != null) session.close();
|
||||
}
|
||||
|
||||
/** 费用确认和发送不得提前完成一件代发组托或回退已有履约进度。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void confirmsAndSendsWithoutAdvancingPalletization(String processStatus) {
|
||||
insertInbound(processStatus, "CALCULATED");
|
||||
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isEqualTo(1);
|
||||
assertState(processStatus, "CONFIRMED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isEqualTo(1);
|
||||
assertState("FEE_CONFIRMING".equals(processStatus) ? "WAIT_PALLET" : processStatus, "SENT");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, processStatus)).isZero();
|
||||
}
|
||||
|
||||
/** 待组托只补费用确认资格,不能绕过费用快照的前置状态。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"UNPRICED", "CONFIRMED", "SENT", "VOIDED"})
|
||||
void rejectsConfirmationUnlessFeeIsCalculated(String feeStatus) {
|
||||
insertInbound("WAIT_PALLETIZE", feeStatus);
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", feeStatus);
|
||||
}
|
||||
|
||||
/** 非确认阶段、其他客户和已删除入库单仍不可确认。 */
|
||||
@Test
|
||||
void preservesProcessCustomerAndDeletionGuards() {
|
||||
insertInbound("WAIT_MEASURE", "CALCULATED");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET process_status='WAIT_PALLETIZE' WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(20002L, 88L)).isZero();
|
||||
sql.update("UPDATE fl_inbound_order SET deleted=TRUE WHERE id=88");
|
||||
assertThat(repository.markInboundConfirmed(10001L, 88L)).isZero();
|
||||
assertState("WAIT_PALLETIZE", "CALCULATED");
|
||||
}
|
||||
|
||||
/** 模拟发送前作业状态发生变化,旧状态条件须拒绝写入。 */
|
||||
@Test
|
||||
void rejectsSendWithStaleProcessStatus() {
|
||||
insertInbound("WAIT_SPLIT", "CONFIRMED");
|
||||
assertThat(repository.markInboundFeeSent(10001L, 88L, "WAIT_PALLETIZE")).isZero();
|
||||
assertState("WAIT_SPLIT", "CONFIRMED");
|
||||
}
|
||||
|
||||
/** 准备单个客户的入库单,不调用共享环境。 */
|
||||
private void insertInbound(String processStatus, String feeStatus) {
|
||||
sql.update("INSERT INTO fl_inbound_order(id,tenant_id,process_status,fee_status) VALUES(88,10001,?,?)",
|
||||
processStatus, feeStatus);
|
||||
}
|
||||
|
||||
/** 同时核对物理作业状态与费用状态,防止用改主流程掩盖发送问题。 */
|
||||
private void assertState(String processStatus, String feeStatus) {
|
||||
assertThat(sql.queryForObject("SELECT process_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(processStatus);
|
||||
assertThat(sql.queryForObject("SELECT fee_status FROM fl_inbound_order WHERE id=88", String.class))
|
||||
.isEqualTo(feeStatus);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -194,8 +194,9 @@ class FbaPricingRepositoryRuleListTest {
|
||||
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
|
||||
}
|
||||
|
||||
/** 费用确认状态集覆盖待组托及后续履约阶段。 */
|
||||
@Test
|
||||
void feeConfirmationAllowsPostLoadingInboundStatuses() {
|
||||
void feeConfirmationAllowsPalletizationAndPostLoadingInboundStatuses() {
|
||||
String source;
|
||||
try {
|
||||
source = Files.readString(Path.of(
|
||||
@@ -205,7 +206,7 @@ class FbaPricingRepositoryRuleListTest {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
assertThat(source)
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_PALLET\", \"WAIT_PALLETIZE\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-3
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TruckAppointmentMapperContractTest {
|
||||
/** 候选不依赖附件,仍保留提单号、状态、唯一预约和乐观锁条件。 */
|
||||
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
|
||||
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
|
||||
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
|
||||
@@ -18,9 +19,10 @@ class TruckAppointmentMapperContractTest {
|
||||
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
|
||||
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
|
||||
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'",
|
||||
"o.bill_of_lading_no IS NOT NULL", "o.bill_of_lading_no<>''", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('CANCELLED','EXCEPTION')");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_document", "bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
||||
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
||||
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
|
||||
@@ -297,21 +297,23 @@ class ContainerServiceTest {
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 提柜补录使用相同的非 ISO 规则,并按版本原子保存。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "LOCAL-2026-001"})
|
||||
@DisplayName("提柜后按柜次版本原子补录真实柜号")
|
||||
void assignsRealContainerNumberAfterPickup() {
|
||||
void assignsRealContainerNumberAfterPickup(String containerNo) {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
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.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
|
||||
when(repository.assignRealContainerNo(11L, containerNo, 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
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");
|
||||
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
|
||||
assertThat(result.getContainerNo()).isEqualTo(containerNo);
|
||||
verify(repository).assignRealContainerNo(11L, containerNo, 3, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -22,6 +22,9 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.R
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -223,11 +226,13 @@ class FbaPricingServiceTest {
|
||||
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
|
||||
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||
/** 待组托、待分柜确认费用均不推进物流主流程。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
@DisplayName("待组托或待分柜确认费用后仍保持主流程状态")
|
||||
void confirmsWithoutRollingBackProcessStatus(String processStatus) {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
@@ -244,7 +249,7 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse response = service.confirm(
|
||||
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo(processStatus);
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@@ -436,11 +441,13 @@ class FbaPricingServiceTest {
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 待组托发送费用保留主状态,重复请求不重复生成费用事件。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"FEE_CONFIRMING,WAIT_PALLET", "WAIT_PALLETIZE,WAIT_PALLETIZE"})
|
||||
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
|
||||
void sendIsIdempotentAndDoesNotDuplicateOutbox(String processStatus, String expectedStatus) {
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("FEE_CONFIRMING");
|
||||
inbound.setProcessStatus(processStatus);
|
||||
inbound.setFeeStatus("CONFIRMED");
|
||||
InboundFeeEntity confirmed = savedFee("CONFIRMED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
@@ -448,7 +455,7 @@ class FbaPricingServiceTest {
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_SEND", "send-once"))
|
||||
.thenReturn(null, completed("FEE_SEND", 501L));
|
||||
when(repository.sendFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, "FEE_CONFIRMING")).thenReturn(1);
|
||||
when(repository.markInboundFeeSent(TENANT, INBOUND, processStatus)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(confirmed);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
@@ -459,8 +466,8 @@ class FbaPricingServiceTest {
|
||||
FeeSnapshotResponse first = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
FeeSnapshotResponse repeated = service.send(TENANT, OPERATOR, INBOUND, "send-once");
|
||||
|
||||
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
|
||||
assertThat(first.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
assertThat(repeated.nextProcessStatus()).isEqualTo(expectedStatus);
|
||||
verify(financeOutbox, times(1)).publishInbound(
|
||||
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
|
||||
eq(501L), eq(1), eq(NOW));
|
||||
|
||||
+17
@@ -101,6 +101,23 @@ class LoadingCompletedOutboxServiceTest {
|
||||
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() {
|
||||
Row value = row(11, 100, 101); value.setContainerNo(" ");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
@@ -168,17 +168,20 @@ class LoadingListServiceTest {
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
|
||||
@Test
|
||||
void stillRejectsNewNonIsoNumberInTransportCorrection() {
|
||||
/** 运输纠错与关联入口一致,只规范化柜号并保留纠错原因。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumberInTransportCorrection(String containerNo) {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("LOCAL-1");
|
||||
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",
|
||||
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "non-iso-correction",
|
||||
new TransportUpdateRequest(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " ", null, 3, "纠错"));
|
||||
|
||||
verify(repository).updateTransport(10L, 3, containerNo, null, 9L, "operator");
|
||||
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
|
||||
}
|
||||
|
||||
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
|
||||
@@ -235,7 +238,7 @@ class LoadingListServiceTest {
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
|
||||
new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
verify(repository, never()).updateTransport(eq(10L), eq(3),
|
||||
org.mockito.ArgumentMatchers.nullable(String.class),
|
||||
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
|
||||
|
||||
@@ -962,10 +962,10 @@ class LoadingPickServiceTest {
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
|
||||
/** 关联接受的非 ISO 柜号可直接确认预装,保留订舱放舱门禁。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumberBeforeConfirmation(String containerNo) {
|
||||
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
|
||||
10L, 40L, containerNo, "EDITING", 3, 2, null);
|
||||
var booking = new ContainerBookingEntity();
|
||||
@@ -974,12 +974,6 @@ class LoadingPickServiceTest {
|
||||
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
|
||||
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();
|
||||
allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity();
|
||||
@@ -990,27 +984,58 @@ class LoadingPickServiceTest {
|
||||
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
|
||||
}
|
||||
|
||||
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
|
||||
/** 非 ISO 柜号可以创建 PDA 任务,任务仍按柜次关联权威柜号。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
|
||||
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
|
||||
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
|
||||
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void createsPdaTaskWithNonIsoNumber(String containerNo) {
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L);
|
||||
var booking = new ContainerBookingEntity();
|
||||
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");
|
||||
}
|
||||
|
||||
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
|
||||
@Test
|
||||
void completionStillRequiresIsoNumberForCrossServiceFacts() {
|
||||
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
/** 去除 ISO 校验不应放开缺号任务。 */
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||
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,
|
||||
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
|
||||
verify(completionCommands, never()).insert(any());
|
||||
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
|
||||
/** 非 ISO 柜号不会在完成阶段再次受阻,仍通过持久化命令结算。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void completionAcceptsNonIsoNumberForCrossServiceFacts(String containerNo) {
|
||||
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
|
||||
|
||||
+20
-4
@@ -36,8 +36,9 @@ class MerchantInboundBillingServiceTest {
|
||||
@MockBean private InboundFeeDetailService feeDetails;
|
||||
@Resource private MerchantInboundBillingService service;
|
||||
|
||||
/** 测量审核通过并发送费用后,待组托与后续履约阶段均可查看客户费用。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"})
|
||||
void shouldKeepPublishedBillingVisibleAfterFulfillmentProgresses(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "APPROVED", "SENT");
|
||||
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
|
||||
@@ -84,9 +85,11 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(orders).findByOrderId(10001L, 77L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase02() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "PENDING", "SENT");
|
||||
/** 待组托不绕过客户费用可见性的测量审核条件。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"WAIT_PALLETIZE", "WAIT_SPLIT"})
|
||||
void shouldHandleCase02(String processStatus) {
|
||||
InboundOrderEntity inbound = inbound(processStatus, "PENDING", "SENT");
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound);
|
||||
|
||||
assertThatThrownBy(() -> service.find(10001L, 77L))
|
||||
@@ -105,6 +108,19 @@ class MerchantInboundBillingServiceTest {
|
||||
verify(pricing, never()).latest(20002L, 88L);
|
||||
}
|
||||
|
||||
/** 客户可在组托前确认已审核并发送的费用,继续使用当前费用版本和幂等键。 */
|
||||
@Test
|
||||
void confirmsPublishedFeeWhileWaitingForPalletization() {
|
||||
when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound("WAIT_PALLETIZE", "APPROVED", "SENT"));
|
||||
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
|
||||
when(pricing.confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize"))
|
||||
.thenReturn(fee);
|
||||
|
||||
assertThat(service.confirmFee(10001L, 7L, "客户成员", 77L, 3, "confirm-palletize"))
|
||||
.isSameAs(fee);
|
||||
verify(pricing).confirmByCustomer(10001L, 7L, "客户成员", 88L, 3, "confirm-palletize");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase04() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT");
|
||||
|
||||
+16
-23
@@ -84,7 +84,7 @@ class TruckAppointmentServiceTest {
|
||||
}
|
||||
|
||||
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true);
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西");
|
||||
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
|
||||
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
|
||||
@@ -170,7 +170,8 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
|
||||
/** 无附件订舱可创建预约,提单号仍取订舱冻结值。 */
|
||||
@Test void createFreezesBookingBillWithoutRequiringBookingDocument() {
|
||||
SaveRequest request = validRequest();
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
@@ -178,7 +179,7 @@ class TruckAppointmentServiceTest {
|
||||
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(entity.capture());
|
||||
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
|
||||
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@@ -307,14 +308,14 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
|
||||
/** 没有附件或货柜关联时,完整已放舱订舱仍可预约。 */
|
||||
@Test void createAllowsBookingWithoutDocumentsOrContainerAssociation() {
|
||||
SaveRequest request = validRequest();
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).insert(any());
|
||||
TruckAppointmentEntity result = service.create(7L, "admin", request);
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(result.getContainerBookingId()).isNull();
|
||||
verify(repository).insert(any());
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
}
|
||||
|
||||
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
|
||||
@@ -407,7 +408,8 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
|
||||
/** 取消附件要求后,派车仍必须具备提单号。 */
|
||||
@Test void dispatchRequiresBookingBill() {
|
||||
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
|
||||
OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null);
|
||||
@@ -419,21 +421,11 @@ class TruckAppointmentServiceTest {
|
||||
"dispatch-no-bill", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
|
||||
|
||||
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
|
||||
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
|
||||
when(repository.find(1L)).thenReturn(missingAttachment);
|
||||
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
|
||||
.when(oceanBookingDocuments).requireActiveValidDocument(any());
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
"dispatch-no-file", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
|
||||
/** 无附件、无真实柜号时可派车,但仍冻结已放舱订舱的提单号并记历史。 */
|
||||
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringDocumentsOrRealContainerNumber() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
@@ -452,6 +444,7 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
|
||||
assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
|
||||
verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1);
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
+37
-2
@@ -11,6 +11,41 @@ import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ContainerCalendarSourceMapperTest {
|
||||
/** 两个日历来源入口均优先展示港口名称,仅填代码时仍可回显,缺失时保持空值。 */
|
||||
@Test
|
||||
void displaysDestinationNameOrSavedCodeAcrossBothEntrypoints() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:calendar-port-display;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||
sql.execute("INSERT INTO fl_container_booking(id,destination_port_name,destination_port_code,record_status) VALUES " +
|
||||
"(1,'洛杉矶','USLAX','ACTIVE'),(2,NULL,'USLAX','ACTIVE'),(3,' ','USLAX','ACTIVE')," +
|
||||
"(4,'洛杉矶',NULL,'ACTIVE'),(5,'',' ','ACTIVE')");
|
||||
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper = session.getMapper(ContainerCalendarSourceMapper.class);
|
||||
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||
var calendar = mapper.sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||
var explicit = mapper.byIds(java.util.List.of(1L, 2L, 3L, 4L, 5L));
|
||||
var expected = java.util.Arrays.asList("洛杉矶", "USLAX", "USLAX", "洛杉矶", null);
|
||||
assertEquals(expected, calendar.stream().map(row -> row.destinationPortName()).toList());
|
||||
for (var row : explicit) {
|
||||
assertEquals(expected.get(row.containerBookingId().intValue() - 1), row.destinationPortName());
|
||||
}
|
||||
assertEquals(5, explicit.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||
@Test
|
||||
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||
@@ -18,7 +53,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||
@@ -65,7 +100,7 @@ class ContainerCalendarSourceMapperTest {
|
||||
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_port_code VARCHAR,destination_warehouse_code VARCHAR," +
|
||||
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
|
||||
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
|
||||
+3
-4
@@ -78,7 +78,6 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -192,7 +191,7 @@ public class DeliveryTransferService {
|
||||
outbound.setVolumeCbm(sum(createdMetricItems, OutboundPalletItem::getVolumeCbm));
|
||||
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK")); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -307,7 +306,7 @@ public class DeliveryTransferService {
|
||||
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
|
||||
deliveryOrders.insert(delivery);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK")); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -459,7 +458,7 @@ public class DeliveryTransferService {
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now);
|
||||
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId);
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK"));
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
transfer.setStatus("WAIT_OUTBOUND");
|
||||
if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim());
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ public class DispatchAggregate {
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
/** 是否向内部散板需求池发布本车余位。 */
|
||||
private Boolean publishLooseboardDemand = true;
|
||||
private Boolean publishLooseboardDemand = false;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ public interface DispatchAggregateMapper {
|
||||
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
|
||||
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount},
|
||||
shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt}
|
||||
WHERE id=#{r.id} AND record_version=#{version}
|
||||
""")
|
||||
|
||||
+61
-8
@@ -11,6 +11,8 @@ import java.util.List;
|
||||
public interface DispatchCandidateMapper {
|
||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||
/** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */
|
||||
String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')";
|
||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||
String DEPARTED_PLAN =
|
||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
@@ -40,6 +42,55 @@ public interface DispatchCandidateMapper {
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1))";
|
||||
/** 收货毛重按逐票确认箱数分摊;零贡献不占重量,任一有效来源缺失毛重时整托保持未知。 */
|
||||
String RECEIVED_GROSS_WEIGHT =
|
||||
"CASE WHEN MIN(CASE WHEN a.counted_package_count=0 THEN 1 WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND l.required_package_count>0 AND a.counted_package_count>0 THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"ROUND(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.counted_package_count / NULLIF(l.required_package_count,0) END),3) END";
|
||||
/** 体积采用收货端记录的逐来源体积,不将计划均分值或部分来源合计冒充完整收货量。 */
|
||||
String RECEIVED_VOLUME =
|
||||
"CASE WHEN MIN(CASE WHEN a.counted_package_count=0 OR a.actual_volume_cbm>0 THEN 1 ELSE 0 END)=1 " +
|
||||
"THEN NULLIF(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE a.actual_volume_cbm END),0) END";
|
||||
/** 已收货候选与箱数共用当前库存范围;入仓身份沿余额关联,不能把尾程订单ID当转运入仓ID。 */
|
||||
String CURRENT_STOCK_FROM =
|
||||
" FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " +
|
||||
"ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " +
|
||||
"LEFT JOIN ll_inbound_order inbound ON inbound.id=balance.inbound_order_id AND inbound.customer_id=stock.customer_id " +
|
||||
"WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id AND stock.available_packages>0";
|
||||
String CURRENT_SOURCE_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " +
|
||||
"THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=stock.customer_id AND d.order_id=inbound.id),inbound.gross_weight_kg)";
|
||||
/** 拆合托和部分出库后的估算按各真实入仓来源剩余箱数汇总,任一来源不明时不返回部分合计。 */
|
||||
String CURRENT_STOCK_WEIGHT =
|
||||
"(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND " + CURRENT_SOURCE_WEIGHT +
|
||||
" >0 THEN 1 ELSE 0 END)=1 THEN ROUND(SUM(" + CURRENT_SOURCE_WEIGHT +
|
||||
" * stock.available_packages / NULLIF(inbound.package_count,0)),3) END" + CURRENT_STOCK_FROM + ")";
|
||||
String CURRENT_STOCK_VOLUME =
|
||||
"(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND inbound.volume_cbm>0 THEN 1 ELSE 0 END)=1 " +
|
||||
"THEN ROUND(SUM(inbound.volume_cbm * stock.available_packages / NULLIF(inbound.package_count,0)),4) END" + CURRENT_STOCK_FROM + ")";
|
||||
/** 仅当前整托仍为同一转运来源、全量可用且未拆合换绑时,保留该托实测;不按订单总重均摊已有实测。 */
|
||||
String CURRENT_MEASUREMENT_FROM =
|
||||
" FROM ll_warehouse_transfer_pallet measurement JOIN ll_warehouse_transfer_order transfer " +
|
||||
"ON transfer.id=measurement.transfer_order_id AND transfer.customer_id=measurement.customer_id " +
|
||||
"WHERE measurement.customer_id=ip.customer_id AND measurement.warehouse_pallet_id=p.warehouse_pallet_id " +
|
||||
"AND transfer.lastleg_order_id=ip.source_order_id AND measurement.status='COMPLETED' AND measurement.completed_at IS NOT NULL " +
|
||||
"AND measurement.carton_count>0 AND measurement.carton_count=(SELECT SUM(stock.available_packages) FROM ll_inventory_position stock " +
|
||||
"WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id) " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " +
|
||||
"ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " +
|
||||
"WHERE stock.pallet_id=p.warehouse_pallet_id AND (stock.available_packages+stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages)>0 " +
|
||||
"AND (stock.customer_id!=ip.customer_id OR balance.inbound_order_id IS NULL OR balance.inbound_order_id!=transfer.inbound_order_id " +
|
||||
"OR stock.overseas_warehouse_id!=transfer.operating_warehouse_id OR stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages>0)) " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger movement WHERE movement.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND movement.created_at>=measurement.completed_at AND movement.business_type IN " +
|
||||
"('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND'))";
|
||||
String CURRENT_GROSS_WEIGHT =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.gross_weight_kg)>0 THEN MAX(measurement.gross_weight_kg) END" +
|
||||
CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_WEIGHT + ")";
|
||||
String CURRENT_VOLUME =
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.volume_cbm)>0 THEN MAX(measurement.volume_cbm) END" +
|
||||
CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_VOLUME + ")";
|
||||
@Select("<script>" +
|
||||
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
@@ -60,11 +111,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
@@ -88,7 +139,8 @@ public interface DispatchCandidateMapper {
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
|
||||
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
@@ -128,7 +180,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
@@ -170,11 +222,11 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
|
||||
" >0 AND (COALESCE(l.volume_cbm,0)<=0 OR COALESCE(a.allocated_volume_cbm,0)>0) THEN 1 ELSE 0 END)=1 THEN " +
|
||||
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " + SOURCE_GROSS_WEIGHT +
|
||||
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
@@ -197,7 +249,8 @@ public interface DispatchCandidateMapper {
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
|
||||
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
@@ -232,7 +285,7 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
|
||||
+6
@@ -21,6 +21,12 @@ public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||
|
||||
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
|
||||
|
||||
/** 逐托交接数量与收货货量;显式写入NULL,避免未知毛重/体积继续保留旧计划值。 */
|
||||
@Update("UPDATE ll_dispatch_item SET warehouse_pallet_id=#{warehousePalletId},pallet_no=#{palletNo},"
|
||||
+ "source_stage=#{sourceStage},package_count=#{packageCount},gross_weight_kg=#{grossWeightKg},volume_cbm=#{volumeCbm} "
|
||||
+ "WHERE id=#{id} AND customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} AND warehouse_pallet_id IS NULL")
|
||||
int confirmReceipt(DispatchItem item);
|
||||
|
||||
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
|
||||
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
|
||||
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
+24
-5
@@ -22,23 +22,42 @@ public interface DispatchSourceMapper {
|
||||
record ItemPicked(Long itemId,long packages) { }
|
||||
|
||||
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
|
||||
@Select("""
|
||||
@Select("<script>" + """
|
||||
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
|
||||
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
|
||||
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
|
||||
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
|
||||
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage,
|
||||
""" + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + " AS grossWeightKg,"
|
||||
+ DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """
|
||||
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL
|
||||
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
|
||||
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
|
||||
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
|
||||
""")
|
||||
""" + "</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
|
||||
|
||||
/** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_pallet p
|
||||
JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
|
||||
WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan}
|
||||
AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse}
|
||||
AND ip.status IN ('COMPLETED','PARTIAL_INBOUND')
|
||||
AND COALESCE(p.actual_package_count,0)=0
|
||||
AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0))
|
||||
AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id
|
||||
WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id)
|
||||
""")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse);
|
||||
|
||||
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
|
||||
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
|
||||
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
|
||||
@@ -113,7 +132,7 @@ public interface DispatchSourceMapper {
|
||||
|
||||
/** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')")
|
||||
+ "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')")
|
||||
int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id);
|
||||
|
||||
/** 退回来源仍使用本票实际实收,不借普通入仓别名。 */
|
||||
|
||||
+4
-4
@@ -24,15 +24,15 @@ public final class DispatchCommands {
|
||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
|
||||
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
|
||||
/** 仅智能排车建单时显式选择发布,未选择时不发布。 */
|
||||
public Create { publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand); }
|
||||
}
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt,
|
||||
String remark, Boolean publishLooseboardDemand, Maintenance maintenance) {
|
||||
String remark, Maintenance maintenance) {
|
||||
/** 缺版本与有效版本零必须区分。 */
|
||||
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
|
||||
public Update { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ public class DispatchPickingRepository {
|
||||
public int pick(DispatchPdaPick pick){return picks.insert(pick);}
|
||||
/** 本明细实际已扫码数量。 */
|
||||
public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);}
|
||||
/** 自然收货后保存已经核实的同一计划来源实托身份。 */
|
||||
public int materialize(DispatchItem item){return items.updateById(item);}
|
||||
/** 自然收货后保存同一来源实托、箱数和货量,允许未知货量清除计划估算。 */
|
||||
public int materialize(DispatchItem item){return items.confirmReceipt(item);}
|
||||
/** 按真实客户取本车扫码,不借主单客户头。 */
|
||||
public List<DispatchPdaPick> picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);}
|
||||
}
|
||||
|
||||
+2
@@ -68,6 +68,8 @@ public class DispatchSourceRepository {
|
||||
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
|
||||
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
|
||||
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
|
||||
/** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */
|
||||
public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; }
|
||||
/** 取得已有真实退回候选。 */
|
||||
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
|
||||
/** 同托真实来源订单组成。 */
|
||||
|
||||
+20
-1
@@ -451,7 +451,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
// 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
|
||||
return saveCommand(row,actor,identity,request.remark());
|
||||
@@ -733,6 +733,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
|
||||
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
|
||||
long version=row.getRecordVersion();
|
||||
// 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。
|
||||
var all=repository.cargo(row.getId());
|
||||
boolean removedItems=!Objects.equals(row.getItemCount(),all.size());
|
||||
int previousPackages=row.getPackageCount();
|
||||
row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum()));
|
||||
row.setItemCount(all.size());
|
||||
row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")));
|
||||
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
|
||||
row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1));
|
||||
if(removedItems || previousPackages!=row.getPackageCount()) {
|
||||
var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
|
||||
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
|
||||
}
|
||||
if(all.isEmpty()) {
|
||||
invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核");
|
||||
row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false;
|
||||
}
|
||||
if(preserveReview)row.setReviewBasisHash(basis(row));
|
||||
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
|
||||
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
|
||||
|
||||
+25
-13
@@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
|
||||
boolean preserveReview=workflow.currentReceiptBasis(row);
|
||||
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
|
||||
for(var item:pending) {
|
||||
var empty=new ArrayList<DispatchItem>();
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) {
|
||||
empty.add(item);
|
||||
continue;
|
||||
}
|
||||
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
|
||||
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
|
||||
received.put(item,actual);
|
||||
@@ -75,15 +81,29 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
for(var entry:received.entrySet()) {
|
||||
entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId());
|
||||
entry.getKey().setPackageCount(entry.getValue().packageCount());
|
||||
// 原预排的均分重量体积在确认入库时一并交接;未知值也必须覆盖旧计划快照。
|
||||
entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg());
|
||||
entry.getKey().setVolumeCbm(entry.getValue().volumeCbm());
|
||||
}
|
||||
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
|
||||
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
|
||||
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|
||||
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|
||||
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
|
||||
}
|
||||
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
// 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。
|
||||
if(!empty.isEmpty()) {
|
||||
var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null);
|
||||
var original=physicalClaims.lockOwner(owner);
|
||||
for(var item:empty) {
|
||||
if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId())))
|
||||
throw conflict("空托盘已有真实拣货,不能按短少移除");
|
||||
if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化");
|
||||
}
|
||||
physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at);
|
||||
}
|
||||
var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
|
||||
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
|
||||
for(var entry:received.entrySet()) {
|
||||
var item=entry.getKey();var actual=entry.getValue();
|
||||
@@ -92,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate
|
||||
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
|
||||
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
|
||||
}
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
|
||||
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
|
||||
if(total!=row.getPackageCount()) {
|
||||
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
|
||||
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
|
||||
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
|
||||
fleet.updateDispatchCargo(number,total,stops);
|
||||
}
|
||||
row.setPackageCount(total);
|
||||
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
|
||||
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList());
|
||||
for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化");
|
||||
workflow.saveReceivedCargo(row,operator,at,preserveReview);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -38,6 +38,23 @@ public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
|
||||
/** 平台核对现场短少后按实收入库,不修改原应收数量。 */
|
||||
@PostMapping("/{planNo}/confirm-shortage")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses.Task> confirmShortage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少");
|
||||
}
|
||||
String customerId = convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
/** 按入仓计划明细分页查询可预约的入库订单行。 */
|
||||
@GetMapping
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */
|
||||
public record ConfirmInboundShortageRequest(
|
||||
@NotBlank String customerId,
|
||||
@NotNull @Min(0) Integer version,
|
||||
@NotNull @Min(1) Integer expectedPackageCount,
|
||||
@NotNull @Min(1) Integer receivedPackageCount,
|
||||
@NotBlank @Size(max = 200) String reason
|
||||
) { }
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway {
|
||||
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
|
||||
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
|
||||
|
||||
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
|
||||
/** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */
|
||||
record Item(long itemId, String customerId, long planPalletId, int packages) { }
|
||||
|
||||
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
|
||||
|
||||
+26
@@ -10,6 +10,32 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingSkuReceiptMapper extends BaseMapper<ReceivingSkuReceipt> {
|
||||
record FrozenSku(String skuCode, long packages) { }
|
||||
|
||||
/** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */
|
||||
@Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages "
|
||||
+ "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id "
|
||||
+ "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))")
|
||||
List<FrozenSku> frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
/** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM ll_inbound_plan_line selected
|
||||
JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id
|
||||
JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id
|
||||
JOIN ll_inbound_plan p ON p.container_no=sp.container_no
|
||||
JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id
|
||||
JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
|
||||
WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId}
|
||||
AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL
|
||||
AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id)
|
||||
AND TRIM(o.container_no)=sp.container_no
|
||||
AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP'
|
||||
AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code))
|
||||
AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA'
|
||||
""")
|
||||
long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId);
|
||||
|
||||
@Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " +
|
||||
"AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE")
|
||||
List<ReceivingSkuReceipt> lockLineSku(@Param("customerId") String customerId,
|
||||
|
||||
+16
@@ -36,6 +36,22 @@ public class PdaWorkflowRepository {
|
||||
@Resource private InboundPalletMergeMapper merges;
|
||||
@Resource private InboundPlanCartonMapper cartons;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts;
|
||||
|
||||
/** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> skuReceipts(String customerId, Long planId) {
|
||||
return skuReceipts.listPlan(customerId, planId);
|
||||
}
|
||||
|
||||
/** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */
|
||||
public List<com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku> frozenSkus(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.frozenSkus(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */
|
||||
public long fbaGroupLineCount(String customerId, Long planId, Long lineId) {
|
||||
return skuReceipts.fbaGroupLineCount(customerId, planId, lineId);
|
||||
}
|
||||
|
||||
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
|
||||
public InboundPlan findPlan(String customerId, String planNo) {
|
||||
|
||||
+81
-1
@@ -716,7 +716,87 @@ public class InboundPdaWorkflowService {
|
||||
repository.update(plan);
|
||||
}
|
||||
|
||||
/** Explicit operator decision to receive only the cartons actually picked. */
|
||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128
|
||||
|| operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) {
|
||||
throw conflict("短少确认必须提供操作人、请求键和确认原因");
|
||||
}
|
||||
InboundPlan original = repository.findPlan(customerId, planNo);
|
||||
if (original == null) throw notFound("入库单不存在或不属于当前客户");
|
||||
dispatchReceipts.lock(List.of(original.getId()));
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
// 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。
|
||||
Map<String, Object> command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request);
|
||||
InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command);
|
||||
if (replay != null) return read(replay, PdaResponses.Task.class);
|
||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||
if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null
|
||||
|| !"PENDING_RECONCILIATION".equals(task.getStatus())) {
|
||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
||||
}
|
||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
||||
}
|
||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
||||
}
|
||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
||||
if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) {
|
||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
||||
}
|
||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
||||
var onlyLine = lines.get(0);
|
||||
var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId());
|
||||
if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1
|
||||
|| frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank()
|
||||
|| frozenSkus.get(0).packages() != expected) {
|
||||
throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属");
|
||||
}
|
||||
if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null
|
||||
|| !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) {
|
||||
throw conflict("实收 SKU 与冻结申报不一致,不能确认短少");
|
||||
}
|
||||
for (InboundPlanLine line : lines) {
|
||||
int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId()))
|
||||
.mapToInt(a -> value(a.getPickedPackageCount())).sum();
|
||||
int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId()))
|
||||
.mapToInt(r -> value(r.getReceivedPackages())).sum();
|
||||
if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) {
|
||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
||||
}
|
||||
}
|
||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||
repository.update(task);
|
||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
|
||||
+13
-4
@@ -48,6 +48,7 @@ public class InboundStockReceiptService {
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
|
||||
|
||||
/** 按完整实收生成库存凭证。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -55,6 +56,7 @@ public class InboundStockReceiptService {
|
||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||
}
|
||||
|
||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -103,7 +105,7 @@ public class InboundStockReceiptService {
|
||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||
if (!exactSkuReceipts.isEmpty()) {
|
||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt));
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
||||
continue;
|
||||
}
|
||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
@@ -143,14 +145,17 @@ public class InboundStockReceiptService {
|
||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||
}
|
||||
|
||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||
Map<Long, InboundPlanPallet> palletById, LastlegOrder order,
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt) {
|
||||
List<ReceivingSkuReceipt> exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) {
|
||||
if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank()
|
||||
|| value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数");
|
||||
long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum();
|
||||
if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total");
|
||||
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), pickedPackages).stream()
|
||||
Map<String, Long> expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream()
|
||||
.collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
Map<String, Long> actualBySku = exactReceipts.stream().collect(Collectors.groupingBy(
|
||||
@@ -159,7 +164,11 @@ public class InboundStockReceiptService {
|
||||
Map<String, Long> normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap(
|
||||
value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum,
|
||||
java.util.LinkedHashMap::new));
|
||||
if (!actualBySku.equals(normalizedExpected)) {
|
||||
boolean invalid = allowPartial
|
||||
? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey())
|
||||
|| entry.getValue() > normalizedExpected.get(entry.getKey()))
|
||||
: !actualBySku.equals(normalizedExpected);
|
||||
if (invalid) {
|
||||
throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -738,7 +738,7 @@ public class ReceivingContainerService {
|
||||
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
||||
&& plannedOrders == totalOrders && planCount == totalOrders;
|
||||
boolean allPlansCompleted = !matchedPlans.isEmpty()
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(plan.getStatus()));
|
||||
boolean shippingReady = facts == null
|
||||
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
|
||||
@@ -812,7 +812,7 @@ public class ReceivingContainerService {
|
||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||
}
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION")
|
||||
.contains(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
|
||||
+8
@@ -37,6 +37,14 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt
|
||||
for(var item:items) {
|
||||
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
|
||||
var physical=repository.physical(item.planPalletId(),item.customerId());
|
||||
// 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。
|
||||
if(item.packages()==0) {
|
||||
if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0)
|
||||
|| original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId()))
|
||||
throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配");
|
||||
binding.removeItem(dispatch,item.itemId());
|
||||
continue;
|
||||
}
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var quantities=new LinkedHashMap<List<String>,Integer>();
|
||||
for(var part:physical) {
|
||||
|
||||
+1
-2
@@ -60,7 +60,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -1147,7 +1146,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
item.setCreatedAt(now); outboundItems.insert(item);
|
||||
}
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo(orderNoGenerator.next("OUTBOUND_TASK"));
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task);
|
||||
if (returnOnly) {
|
||||
Map<String, List<RelabelItem>> destinations = relabelItems.stream().collect(Collectors.groupingBy(
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import java.util.Set;
|
||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||
@Component
|
||||
public class LastlegDocumentNumberGenerator {
|
||||
private static final Set<String> PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
|
||||
private static final Set<String> PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "PDA", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
|
||||
private final DocumentSequenceRepository sequences;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class LastlegOrderNoGenerator {
|
||||
case "OUTBOUND" -> "CK";
|
||||
case "RELABEL_OUTBOUND" -> "HBCK";
|
||||
case "RETURN_OUTBOUND" -> "THCK";
|
||||
case "OUTBOUND_TASK" -> "PDA";
|
||||
case "DELIVERY" -> "DL";
|
||||
case "TRUCK" -> "TR";
|
||||
case "LOOSEBOARD" -> "LB";
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper {
|
||||
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
|
||||
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
|
||||
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded
|
||||
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
|
||||
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
|
||||
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
-- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。
|
||||
ALTER TABLE ll_dispatch_order
|
||||
ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type;
|
||||
ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位' AFTER task_type;
|
||||
|
||||
@@ -1895,7 +1895,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
|
||||
@@ -667,6 +667,32 @@ paths:
|
||||
responses:
|
||||
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
|
||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
||||
post:
|
||||
summary: 确认FBA入库短少并按实收入库
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [customerId, version, expectedPackageCount, receivedPackageCount, reason]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
version: { type: integer, minimum: 0 }
|
||||
expectedPackageCount: { type: integer, minimum: 1 }
|
||||
receivedPackageCount: { type: integer, minimum: 1 }
|
||||
reason: { type: string, minLength: 1, maxLength: 200 }
|
||||
responses:
|
||||
'200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND }
|
||||
'403': { description: 非平台身份或无入库编辑权限 }
|
||||
'404': { description: 入库单不存在或客户不匹配 }
|
||||
'409': { description: 版本、状态、实收数量、SKU或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/inbound-plans/manual:
|
||||
post:
|
||||
summary: 平台按单票手工生成入库计划
|
||||
@@ -3624,7 +3650,7 @@ components:
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady]
|
||||
properties:
|
||||
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目的仓 }
|
||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 }
|
||||
overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 }
|
||||
@@ -4080,10 +4106,6 @@ components:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.Update
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
|
||||
recordVersion:
|
||||
oneOf:
|
||||
- type: string
|
||||
@@ -4118,7 +4140,6 @@ components:
|
||||
nullable: true
|
||||
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
|
||||
required:
|
||||
- publishLooseboardDemand
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
DeliveryStatusChange:
|
||||
@@ -5984,8 +6005,8 @@ components:
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
default: true
|
||||
description: 是否向内部散板需求池发布本车余位;仅智能排车建单时选择,默认不发布。
|
||||
default: false
|
||||
|
||||
idempotencyKey:
|
||||
type: string
|
||||
@@ -6701,7 +6722,7 @@ components:
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
description: 建单时确定的散板需求发布选择,维护车辆不可修改。
|
||||
|
||||
dispatchNo:
|
||||
type: string
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand", "maintenance"),
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark", "maintenance"),
|
||||
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
|
||||
|
||||
+5
-1
@@ -845,9 +845,13 @@ class DeliveryTransferServiceTest {
|
||||
when(inventoryPositions.selectById(901L)).thenReturn(position);
|
||||
when(inventoryBalances.selectById(801L)).thenReturn(balance);
|
||||
|
||||
service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
|
||||
var result = service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
|
||||
new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1")), null, "split-metrics"));
|
||||
|
||||
// 出库单和作业任务各自使用独立日流水,不暴露内部雪花 ID。
|
||||
assertEquals("DFCK202610200001", result.outboundNo());
|
||||
assertEquals("PDA202610200001", result.taskNo());
|
||||
|
||||
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundPallets).insert(item.capture());
|
||||
assertEquals(new BigDecimal("20.000"), item.getValue().getGrossWeightKg());
|
||||
|
||||
+30
-20
@@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */
|
||||
/** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */
|
||||
@Test
|
||||
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
|
||||
var db = connection.createStatement();
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
@@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
|
||||
for (int id = 1; id <= 9; id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')");
|
||||
for (int id = 1; id <= 14; id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
|
||||
@@ -44,24 +44,34 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
|
||||
// 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。
|
||||
db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11");
|
||||
db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)");
|
||||
db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14");
|
||||
String plan = DispatchCandidateMapper.PLAN_ONLY;
|
||||
String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) "
|
||||
+ "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos "
|
||||
+ "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))";
|
||||
var ids = new ArrayList<Long>();
|
||||
try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
||||
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
||||
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
||||
while (rows.next()) {
|
||||
long id = rows.getLong("id"); ids.add(id);
|
||||
assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
||||
assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class));
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(1L, 3L, 7L), ids);
|
||||
// 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。
|
||||
// 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。
|
||||
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
|
||||
assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id")));
|
||||
int start = sql.indexOf("AND (NOT " + plan + " OR (");
|
||||
int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start);
|
||||
int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate);
|
||||
String eligible = sql.substring(start, end).replace(">", ">")
|
||||
.replace("#{customerId}", "'A'");
|
||||
var ids = new ArrayList<Long>();
|
||||
try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
|
||||
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
|
||||
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
|
||||
while (rows.next()) {
|
||||
long id = rows.getLong("id"); ids.add(id);
|
||||
assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage"));
|
||||
assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class));
|
||||
if (id == 11) assertEquals(21, rows.getInt("actual_package_count"));
|
||||
}
|
||||
}
|
||||
// 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。
|
||||
assertEquals(List.of(1L, 3L, 7L, 11L), ids);
|
||||
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
|
||||
}
|
||||
}
|
||||
|
||||
+133
-6
@@ -10,10 +10,11 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchPhysicalWeightTest {
|
||||
/** 计划估算不能用材积重补毛重,混合来源缺失时保持未知。 */
|
||||
@Test void billingVolumeWeightCannotFillMissingPhysicalWeight() {
|
||||
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))");
|
||||
currentStockSchema(jdbc);
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)");
|
||||
var tested = new java.util.HashSet<String>();
|
||||
@@ -30,15 +31,17 @@ class DispatchPhysicalWeightTest {
|
||||
assertThat(jdbc.queryForObject("SELECT " + projection + " FROM (SELECT 80 gross_weight_kg) wtp", BigDecimal.class)).isEqualByComparingTo("80");
|
||||
continue;
|
||||
}
|
||||
String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip "
|
||||
+ "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l "
|
||||
+ "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a "
|
||||
+ "CROSS JOIN (SELECT 999 AS volume_weight_kg) o";
|
||||
String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip "
|
||||
+ "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count) l "
|
||||
+ "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm,10 AS counted_package_count) a "
|
||||
+ "CROSS JOIN (SELECT 999 AS volume_weight_kg) o "
|
||||
+ "CROSS JOIN (SELECT CAST(NULL AS BIGINT) AS warehouse_pallet_id,'RESERVED' AS assignment_status,0 AS actual_package_count) p "
|
||||
+ "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,ip.customer_id,ip.source_order_id";
|
||||
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull();
|
||||
if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet.
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)");
|
||||
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20");
|
||||
String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10");
|
||||
String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count", "SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count UNION ALL SELECT 2,10,20");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull();
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40");
|
||||
@@ -47,4 +50,128 @@ class DispatchPhysicalWeightTest {
|
||||
}
|
||||
assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected");
|
||||
}
|
||||
|
||||
/** 执行正式候选投影,21箱两托计划可均分,实收10+11后必须切换到对应货量。 */
|
||||
@Test void receivedCandidatesUseTenAndElevenCartonsInsteadOfEqualPlanShares() {
|
||||
var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""));
|
||||
jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))");
|
||||
currentStockSchema(jdbc);
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,570.5),('other',1,9999)");
|
||||
jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',570.5,21,3.528)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1)");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0)");
|
||||
var tested=new java.util.HashSet<String>();
|
||||
for(var method:DispatchCandidateMapper.class.getDeclaredMethods()) {
|
||||
if(!java.util.Set.of("available","wholeAvailable","readSelected","lockSelected").contains(method.getName()))continue;
|
||||
String sql=String.join(" ",method.getAnnotation(Select.class).value()).replace(">",">").replace("<","<");
|
||||
String weight=sql.substring(sql.indexOf("AS volumeWeightKg,")+"AS volumeWeightKg,".length(),sql.indexOf(" AS grossWeightKg,"));
|
||||
String volume=sql.substring(sql.indexOf("AS packageCount,")+"AS packageCount,".length(),sql.indexOf(" AS volumeCbm,"));
|
||||
String from=" FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip "
|
||||
+ "CROSS JOIN (SELECT 1 AS order_id,3.528 AS volume_cbm,21 AS required_package_count) l "
|
||||
+ "CROSS JOIN (SELECT 1 AS line_id,1.764 AS allocated_volume_cbm,10 AS counted_package_count,1.680 AS actual_volume_cbm) a "
|
||||
+ "CROSS JOIN (SELECT 501 AS warehouse_pallet_id,'RECEIVING' AS assignment_status,10 AS actual_package_count,1.764 AS allocated_volume_cbm) p "
|
||||
+ "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,p.allocated_volume_cbm,ip.customer_id,ip.source_order_id";
|
||||
assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).as(method.getName()).isEqualByComparingTo("271.667");
|
||||
assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isEqualByComparingTo("1.680");
|
||||
String second=from.replace("10 AS counted_package_count,1.680 AS actual_volume_cbm","11 AS counted_package_count,1.848 AS actual_volume_cbm");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=11");
|
||||
assertThat(jdbc.queryForObject("SELECT "+weight+second,BigDecimal.class)).isEqualByComparingTo("298.833");
|
||||
assertThat(jdbc.queryForObject("SELECT "+volume+second,BigDecimal.class)).isEqualByComparingTo("1.848");
|
||||
String plan=from.replace("'RECEIVING' AS assignment_status,10 AS actual_package_count","'RESERVED' AS assignment_status,0 AS actual_package_count");
|
||||
assertThat(jdbc.queryForObject("SELECT "+weight+plan,BigDecimal.class)).isEqualByComparingTo("285.25");
|
||||
assertThat(jdbc.queryForObject("SELECT "+volume+plan,BigDecimal.class)).isEqualByComparingTo("1.764");
|
||||
jdbc.update("UPDATE ll_inbound_order SET package_count=0");
|
||||
assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull();
|
||||
jdbc.update("UPDATE ll_inbound_order SET package_count=21,volume_cbm=0");
|
||||
assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isNull();
|
||||
jdbc.update("UPDATE ll_inbound_order SET volume_cbm=3.528");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',2,501,'9',1,0,0,0,0)");
|
||||
assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull();
|
||||
jdbc.update("DELETE FROM ll_inventory_position WHERE id=2");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=10");
|
||||
tested.add(method.getName());
|
||||
}
|
||||
assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected");
|
||||
}
|
||||
|
||||
/** 拆出、部分出库及合入其他来源后,按当前来源组成重算;同客户同箱数也不能复用旧货量。 */
|
||||
@Test void currentStockMeasurementsFollowSplitPartialShipmentAndMergedSources() {
|
||||
var jdbc=measurementDatabase();
|
||||
jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',200,20,4),(2,'10',300,10,3),(3,'other',9000,10,9)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1),(2,'10',2),(3,'other',3)");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(3,'other',3,501,'9',10,0,0,0,0)");
|
||||
assertMetrics(jdbc,501,"100","2");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',1,502,'9',5,0,0,0,0)");
|
||||
assertMetrics(jdbc,501,"50","1");assertMetrics(jdbc,502,"50","1");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=2,picked_packages=3 WHERE id=1");
|
||||
assertMetrics(jdbc,501,"20","0.4");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(4,'10',2,501,'9',8,0,0,0,0)");
|
||||
assertMetrics(jdbc,501,"260","2.8");
|
||||
// 任一有效库存来源无法解析时不能只返回其他来源的部分合计。
|
||||
jdbc.update("UPDATE ll_inventory_balance SET inbound_order_id=999 WHERE id=2");
|
||||
assertMetrics(jdbc,501,null,null);
|
||||
}
|
||||
|
||||
/** 尾程订单9001与入仓快照7001不同,仍按库存真实关联读取各托实测,而不是均摊总重量。 */
|
||||
@Test void transferMeasurementsUseLinkedInboundAndPreserveDifferentPalletWeights() {
|
||||
var jdbc=measurementDatabase();
|
||||
jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(9001,'other',9000,20,9)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001)");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(2,'10',1,502,'9',10,0,0,0,0)");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1),(80,'10',502,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,200,2)");
|
||||
assertMetrics(jdbc,501,"100","1");assertMetrics(jdbc,502,"200","2");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1");
|
||||
assertMetrics(jdbc,501,"75","0.75");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=10 WHERE id=1");
|
||||
jdbc.update("INSERT INTO ll_inventory_ledger VALUES(501,TIMESTAMP '2026-10-23 02:00:00','PALLET_DISASSEMBLE')");
|
||||
assertMetrics(jdbc,501,"150","1.5");
|
||||
}
|
||||
|
||||
/** 同托混入其他来源或冻结货物后,旧整托测量不能冒充当前可用份额;未知单项分别保留。 */
|
||||
@Test void changedCompositionAndMissingMetricsCannotReuseWholePalletMeasurements() {
|
||||
var jdbc=measurementDatabase();
|
||||
jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(7002,'10',400,20,4)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001),(2,'10',7002)");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',5,0,0,0,0),(2,'10',2,501,'9',5,0,0,0,0)");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')");
|
||||
jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1)");
|
||||
assertMetrics(jdbc,501,"175","1.75");
|
||||
jdbc.update("DELETE FROM ll_inventory_position WHERE id=2");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=10,hold_packages=5");
|
||||
assertMetrics(jdbc,501,"150","1.5");
|
||||
jdbc.update("UPDATE ll_inbound_order SET gross_weight_kg=NULL WHERE id=7001");
|
||||
assertMetrics(jdbc,501,null,"1.5");
|
||||
jdbc.update("UPDATE ll_inbound_order SET package_count=0 WHERE id=7001");
|
||||
assertMetrics(jdbc,501,null,null);
|
||||
}
|
||||
|
||||
/** 每个场景独享内存数据,执行生产SQL而不连接共享环境。 */
|
||||
private static JdbcTemplate measurementDatabase() {
|
||||
var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""));
|
||||
jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))");
|
||||
currentStockSchema(jdbc);return jdbc;
|
||||
}
|
||||
|
||||
/** 直接执行候选共用的生产货量表达式,分别检查未知值和精度。 */
|
||||
private static void assertMetrics(JdbcTemplate jdbc,long pallet,String weight,String volume) {
|
||||
String sql=("SELECT "+DispatchCandidateMapper.CURRENT_GROSS_WEIGHT+" AS weight,"+DispatchCandidateMapper.CURRENT_VOLUME+" AS volume "
|
||||
+"FROM (SELECT '10' AS customer_id,9001 AS source_order_id) ip CROSS JOIN (SELECT CAST(? AS BIGINT) AS warehouse_pallet_id) p")
|
||||
.replace(">",">").replace("<","<");
|
||||
var values=jdbc.queryForObject(sql,(rs,n)->new BigDecimal[]{rs.getBigDecimal("weight"),rs.getBigDecimal("volume")},pallet);
|
||||
if(weight==null)assertThat(values[0]).isNull();else assertThat(values[0]).isEqualByComparingTo(weight);
|
||||
if(volume==null)assertThat(values[1]).isNull();else assertThat(values[1]).isEqualByComparingTo(volume);
|
||||
}
|
||||
|
||||
/** 与生产库存来源及转运实测相同的最小表结构,不借旧计划数量充当当前库存。 */
|
||||
private static void currentStockSchema(JdbcTemplate jdbc) {
|
||||
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3),package_count INT,volume_cbm DECIMAL(18,4))");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR,available_packages INT,hold_packages INT,allocated_packages INT,picked_packages INT,receiving_packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,inbound_order_id BIGINT,operating_warehouse_id VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,status VARCHAR,completed_at TIMESTAMP,carton_count INT,gross_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4))");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_ledger(pallet_id BIGINT,created_at TIMESTAMP,business_type VARCHAR)");
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ public final class DispatchPortCompileContract {
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本");
|
||||
}
|
||||
require(!fields(DispatchCommands.Update.class).contains("publishLooseboardDemand"), "发布选择仅在创建时确定,维护车辆不得修改");
|
||||
require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位");
|
||||
require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位");
|
||||
require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留");
|
||||
|
||||
+3
-1
@@ -39,12 +39,14 @@ public final class DispatchPublicJsonContract {
|
||||
rejectsMissing(requests, request, "{}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":null}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":-1}");
|
||||
Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request);
|
||||
Object zero = requests.readValue("{\"recordVersion\":0}", request);
|
||||
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
|
||||
}
|
||||
rejectsMissing(requests, DispatchCommands.Update.class,
|
||||
"{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}");
|
||||
|
||||
rejectsMissing(requests, DispatchCommands.Update.class, "{\"recordVersion\":0,\"publishLooseboardDemand\":true}");
|
||||
|
||||
Instant now = Instant.parse("2026-10-19T00:00:00Z");
|
||||
var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1);
|
||||
var destination = new Destination("FBA:ONT8", "FBA", "1", "ONT8", "目的仓", null, null, null, null);
|
||||
|
||||
+2
-2
@@ -249,7 +249,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null);
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", null);
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
@@ -289,7 +289,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
}
|
||||
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
|
||||
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null);
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", null);
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
|
||||
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
|
||||
|
||||
+23
-8
@@ -9,22 +9,37 @@ import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchCapacityDemandPersistenceTest {
|
||||
/** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */
|
||||
@Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception {
|
||||
/** 未选择发布的新排车保持私有,维护与重放均不能开启发布。 */
|
||||
@Test void defaultsToUnpublishedAndMaintenanceCannotEnablePublication() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,request));
|
||||
var service=new DispatchCapacityDemandService(f.repository,f.json);
|
||||
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
|
||||
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
|
||||
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null);
|
||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
|
||||
assertThat(service.page(query()).total()).isZero();
|
||||
var update=new DispatchCommands.Update(created.recordVersion(),"pool-maintain-private",null,null,null,null,"补充资料",null);
|
||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());
|
||||
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
|
||||
assertThat(service.page(query()).total()).isZero();
|
||||
}
|
||||
/** 建单发布后维护不改变发布;重发须取消原排车,再建新单。 */
|
||||
@Test void preservesPublicationUntilCancellationAndRepublishesOnlyOnNewDispatch() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-publish",List.of())));
|
||||
var service=new DispatchCapacityDemandService(f.repository,f.json);
|
||||
var update=new DispatchCommands.Update(created.recordVersion(),"pool-maintain-public",null,null,null,null,"补充资料",null);
|
||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());
|
||||
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
|
||||
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
|
||||
f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),new DispatchCommands.ReasonCommand(saved.recordVersion(),"pool-cancel","取消后重新创建")));
|
||||
assertThat(service.page(query()).total()).isZero();
|
||||
var recreated=f.tx.execute(s->f.service.create(f.actor,f.create("pool-republish",List.of())));
|
||||
assertThat(recreated.dispatchNo()).isNotEqualTo(created.dispatchNo());
|
||||
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(recreated.dispatchNo());
|
||||
}
|
||||
/** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */
|
||||
@Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -63,7 +78,7 @@ class DispatchCapacityDemandPersistenceTest {
|
||||
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load));
|
||||
assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero();
|
||||
}
|
||||
/** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */
|
||||
/** 新建时显式不发布,查询只按真实仓库与路线筛选。 */
|
||||
@Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of());
|
||||
var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false);
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ class DispatchDestinationAppointmentPersistenceTest {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
assertThat(f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments()).hasSize(1);
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"missing",null)))).hasMessageContaining("ONT8");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
|
||||
var loaded=f.approve(saved);assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
|
||||
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(detail.dispatchInfo().fbaAppointments()).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE"));
|
||||
@@ -23,9 +23,9 @@ class DispatchDestinationAppointmentPersistenceTest {
|
||||
@Test void savingOneOfTwoNumbersIsAllowedButReviewNeedsBoth() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("two-dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LGB8",5)));
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"second-missing",null)))).hasMessageContaining("LGB8");
|
||||
var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO"))))));
|
||||
var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO"))))));
|
||||
assertThat(f.approve(complete).stage()).isEqualTo(Stage.LOADING);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ class DispatchFeedbackRegressionTest {
|
||||
var input=f.configured("update",List.of());
|
||||
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
|
||||
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))),
|
||||
input.plannedDepartureAt(),null,null,true,null);
|
||||
input.plannedDepartureAt(),null,null,null);
|
||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
f.prepareAppointmentNumbers(created.dispatchNo());
|
||||
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
|
||||
@@ -78,7 +78,7 @@ class DispatchFeedbackRegressionTest {
|
||||
var update=new DispatchCommands.Update(created.recordVersion(),"save-draft",
|
||||
new DispatchCommands.Vehicle(v.mode(),null,v.plate(),v.carrierName(),v.capacity(),List.of()),
|
||||
new DispatchCommands.Driver(d.mode(),null,d.name(),d.phone(),new DispatchContracts.DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),List.of(),e.expectedTaskEndAt(),List.of())),
|
||||
input.plannedDepartureAt(),null,null,true,null);
|
||||
input.plannedDepartureAt(),null,null,null);
|
||||
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
f.prepareAppointmentNumbers(created.dispatchNo());
|
||||
|
||||
+127
-7
@@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
@@ -23,30 +24,136 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
||||
class DispatchInboundReceiptPersistenceTest {
|
||||
/** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
||||
/** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */
|
||||
@Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
f.p.f.candidates.get(1L).packageCount(20);
|
||||
f.p.f.candidates.get(2L).packageCount(0);
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
|
||||
f.confirm(20,0);
|
||||
assertThat(f.quantities()).containsExactly(20);
|
||||
assertThat(f.allocations()).containsExactly(20);
|
||||
var row=f.p.f.repository.find(f.p.number);
|
||||
assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE");
|
||||
var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version);
|
||||
var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue();
|
||||
verify(f.p.inventory).ship(eq("101"),eq("picker"),any());
|
||||
}
|
||||
|
||||
/** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */
|
||||
@Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2");
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用");
|
||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 零候选但没有明确零实收证明,不能作为空托删除。 */
|
||||
@Test void missingEmptyReceiptEvidenceStillRejects() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0);
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘");
|
||||
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */
|
||||
@Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9"));
|
||||
f.confirm(0,0);
|
||||
assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty();
|
||||
var row=f.p.f.repository.find(f.p.number);
|
||||
assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero();
|
||||
assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT");
|
||||
assertThat(row.getReviewBasisHash()).isNull();
|
||||
}
|
||||
|
||||
/** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */
|
||||
@Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception {
|
||||
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
|
||||
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
|
||||
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1");
|
||||
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
|
||||
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
|
||||
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR,order_id BIGINT,required_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT,actual_volume_cbm DECIMAL(18,4))");
|
||||
db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3))");
|
||||
db.update("INSERT INTO ll_order_detail VALUES('101',9001,570.5),('other',9001,9999)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8',9001,20)");
|
||||
for(int n=1;n<=2;n++) {
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?,?)",n,n==1?9:11,new BigDecimal(n==1?"1.5876":"1.9404"));
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11);
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isEqualByComparingTo("256.725");
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).grossWeightKg()).isEqualByComparingTo("313.775");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).volumeCbm()).isEqualByComparingTo("1.5876");
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).volumeCbm()).isEqualByComparingTo("1.9404");
|
||||
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
|
||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
||||
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
||||
}
|
||||
|
||||
/** 原排车重量体积随实收交接持久化,重复确认不覆盖已经交接的快照。 */
|
||||
@Test void receiptReplacesEqualPlannedMeasurementsWithUnequalReceivedMeasurements() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764");
|
||||
f.p.f.candidates.get(1L).grossWeightKg(new BigDecimal("256.725"));
|
||||
f.p.f.candidates.get(1L).volumeCbm(new BigDecimal("1.5876"));
|
||||
f.p.f.candidates.get(2L).grossWeightKg(new BigDecimal("313.775"));
|
||||
f.p.f.candidates.get(2L).volumeCbm(new BigDecimal("1.9404"));
|
||||
f.confirm();f.confirm();
|
||||
var items=f.p.f.repository.cargo(f.p.f.repository.find(f.p.number).getId());
|
||||
items=items.stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchItem::getPlanPalletId)).toList();
|
||||
assertThat(items.get(0).getGrossWeightKg()).isEqualByComparingTo("256.725");
|
||||
assertThat(items.get(1).getGrossWeightKg()).isEqualByComparingTo("313.775");
|
||||
assertThat(items.get(0).getVolumeCbm()).isEqualByComparingTo("1.5876");
|
||||
assertThat(items.get(1).getVolumeCbm()).isEqualByComparingTo("1.9404");
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
}
|
||||
|
||||
/** 收货来源缺失货量时显式清空旧估算,不能因MyBatis忽略NULL而显示过期计划值。 */
|
||||
@Test void missingReceivedMeasurementsClearPlannedValues() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764");
|
||||
f.p.f.candidates.values().forEach(c->{c.grossWeightKg(null);c.volumeCbm(null);});
|
||||
f.confirm();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE gross_weight_kg IS NOT NULL OR volume_cbm IS NOT NULL",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */
|
||||
@Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception {
|
||||
var f=new Fixture();f.confirm();
|
||||
@@ -103,6 +210,7 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_order SET destination_type='PRIVATE' WHERE id=9001");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch);
|
||||
f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE"));
|
||||
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
|
||||
@@ -149,12 +257,19 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0");
|
||||
p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'");
|
||||
// 审核按当前库存及已拣归属核平;补齐实托、库存账和订单之间的真实读取关系。
|
||||
p.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT)");
|
||||
p.f.db.execute("ALTER TABLE ll_order ADD (inbound_code VARCHAR(128),destination_type VARCHAR(20),fba_warehouse_code VARCHAR(128))");
|
||||
p.f.db.update("UPDATE ll_order SET inbound_code='IN-1',destination_type='FBA',fba_warehouse_code='ONT8' WHERE id=9001");
|
||||
p.f.db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(21,'101','IN-1'),(22,'101','IN-1')");
|
||||
p.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,'101',501),(2,'101',502)");
|
||||
p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)");
|
||||
p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8");
|
||||
p.f.candidates.get(2L).destinationWarehouseCode("ONT8");
|
||||
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
|
||||
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
|
||||
for(long id=1;id<=2;id++) {
|
||||
doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9"));
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
|
||||
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
|
||||
doReturn(id).when(p.sources).lockPlan("101",id);
|
||||
@@ -193,10 +308,15 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
|
||||
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
||||
void confirm() {
|
||||
confirm(9,11);
|
||||
}
|
||||
|
||||
/** 按指定真实数量验证短少和空托交接的同事务持久化。 */
|
||||
void confirm(int first,int second) {
|
||||
p.f.tx.executeWithoutResult(s->{
|
||||
receipt.lock(List.of(1L,2L));
|
||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
|
||||
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second);
|
||||
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second);
|
||||
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -66,13 +66,13 @@ class DispatchListAndResourceRegressionTest {
|
||||
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
|
||||
var input=f.configured("input",List.of());
|
||||
var requestDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L)));
|
||||
var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,true,null)));
|
||||
var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,null)));
|
||||
var snapshot=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver();var proof=snapshot.evidence();
|
||||
when(driver.licenseFileId()).thenReturn("82");
|
||||
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");clearInvocations(evidence);
|
||||
doThrow(new IllegalArgumentException("旧主档证照不能按本车附件读取")).when(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
|
||||
var changedDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,snapshot.name(),snapshot.phone(),new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),91L));
|
||||
f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,true,null)));
|
||||
f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,null)));
|
||||
var saved=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
|
||||
assertThat(saved.fileIds()).containsExactly(82L,91L);assertThat(saved.masterLicenseFileId()).isEqualTo(82L);
|
||||
verify(evidence,never()).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
|
||||
|
||||
+5
-5
@@ -31,7 +31,7 @@ class DispatchMaintenancePersistenceTest {
|
||||
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装",
|
||||
" fleet@example.test ", "厢式货车", " account ", List.of(new com.baiyue.lastleg.dispatch.port.DispatchContracts.DestinationAppointment("FBA:ONT8", " appointment ")));
|
||||
var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(),
|
||||
input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance);
|
||||
input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", maintenance);
|
||||
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
|
||||
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
|
||||
f.approve(saved);
|
||||
@@ -63,7 +63,7 @@ class DispatchMaintenancePersistenceTest {
|
||||
var dispatchId = f.repository.find(created.dispatchNo()).getId();
|
||||
doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L);
|
||||
var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)),
|
||||
input.driver(), input.plannedDepartureAt(), null, null, true, null);
|
||||
input.driver(), input.plannedDepartureAt(), null, null, null);
|
||||
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason);
|
||||
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero();
|
||||
@@ -76,9 +76,9 @@ class DispatchMaintenancePersistenceTest {
|
||||
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"destinationAppointments\":[]}", Maintenance.class);
|
||||
assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null));
|
||||
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
|
||||
created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty)));
|
||||
created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, empty)));
|
||||
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
|
||||
saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null)));
|
||||
saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, null)));
|
||||
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
|
||||
assertThat(detail.vehicleInfo().maintenance()).isNull();
|
||||
assertThat(detail.dispatchInfo().departedAt()).isNull();
|
||||
@@ -112,7 +112,7 @@ class DispatchMaintenancePersistenceTest {
|
||||
var created = f.tx.execute(t -> f.service.create(f.actor, input));
|
||||
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, List.of());
|
||||
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(),
|
||||
"projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance)));
|
||||
"projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, maintenance)));
|
||||
var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository,
|
||||
mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class),
|
||||
mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json);
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
|
||||
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
|
||||
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
|
||||
for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) {
|
||||
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
|
||||
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
|
||||
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
|
||||
var e = d.evidence();
|
||||
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
|
||||
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
|
||||
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null);
|
||||
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", null);
|
||||
}
|
||||
|
||||
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
|
||||
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
|
||||
var resources=f.configured("updated-resources",List.of());
|
||||
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null)));
|
||||
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", null)));
|
||||
var detail=f.service.detail(f.actor,created.dispatchNo());
|
||||
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
|
||||
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
|
||||
|
||||
+13
@@ -31,6 +31,7 @@ class InboundPlanControllerTest {
|
||||
@Mock InboundPlanService service;
|
||||
@Mock InboundFbaAppointmentService appointmentService;
|
||||
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
|
||||
private InboundPlanController controller;
|
||||
|
||||
@BeforeEach
|
||||
@@ -39,6 +40,18 @@ class InboundPlanControllerTest {
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
|
||||
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
|
||||
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
|
||||
}
|
||||
|
||||
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
|
||||
@Test void platformConfirmsShortageWithExplicitCustomer() {
|
||||
var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少");
|
||||
controller.confirmShortage(platform(), "IP-1", "key", request);
|
||||
verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request);
|
||||
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class);
|
||||
var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少");
|
||||
assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundShortageConfirmationTest {
|
||||
@Mock PdaWorkflowRepository repository;
|
||||
@Mock InboundStockReceiptService stock;
|
||||
@Mock InboundDispatchReceiptGateway dispatch;
|
||||
@Mock InboundUnloadCompletedOutboxService outbox;
|
||||
InboundPdaWorkflowService service;
|
||||
InboundPlan plan;
|
||||
InboundPdaTask task;
|
||||
InboundPlanLine line;
|
||||
InboundPlanPalletAllocation allocation;
|
||||
final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
|
||||
/** 准备已结束现场收货、应收21实收20的冻结事实。 */
|
||||
@BeforeEach void setup() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stock);
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
|
||||
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4);
|
||||
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
|
||||
task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt);
|
||||
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
|
||||
line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21);
|
||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L);
|
||||
allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20);
|
||||
var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20);
|
||||
when(repository.findPlan("100", "IP-1")).thenReturn(plan);
|
||||
lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan);
|
||||
lenient().when(repository.lockTask("100", 1L)).thenReturn(task);
|
||||
lenient().when(repository.lines(1L)).thenReturn(List.of(line));
|
||||
lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation));
|
||||
lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku));
|
||||
lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21)));
|
||||
lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L);
|
||||
}
|
||||
|
||||
/** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */
|
||||
@Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() {
|
||||
var request = request(4, 20);
|
||||
var result = service.confirmShortage("100", "IP-1", "admin", "key", request);
|
||||
assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount());
|
||||
assertEquals(receivedAt, task.getCompletedAt());
|
||||
var sequence = inOrder(dispatch, repository, stock);
|
||||
sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1");
|
||||
sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
|
||||
sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any());
|
||||
var audit = ArgumentCaptor.forClass(InboundPdaOperation.class);
|
||||
verify(repository).insert(audit.capture());
|
||||
assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱"));
|
||||
when(repository.operation("100", "key")).thenReturn(audit.getValue());
|
||||
assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request));
|
||||
verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
||||
verifyNoInteractions(outbox);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||
}
|
||||
|
||||
/** 页面旧版本或旧实收数不能确认库存。 */
|
||||
@Test void rejectsStaleVersionAndCounts() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */
|
||||
@Test void rejectsMissingSkuReceipts() {
|
||||
when(repository.skuReceipts("100", 1L)).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */
|
||||
@Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() {
|
||||
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20),
|
||||
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1)));
|
||||
var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
||||
assertTrue(error.getReason().contains("单 SKU"));
|
||||
verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any());
|
||||
assertEquals("PENDING_RECONCILIATION",plan.getStatus());
|
||||
}
|
||||
|
||||
/** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */
|
||||
@Test void rejectsSharedFbaReceivingGroup() {
|
||||
when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L);
|
||||
assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
|
||||
verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class));
|
||||
}
|
||||
|
||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22})
|
||||
void rejectsZeroCompleteAndExcessReceipts(int received) {
|
||||
allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */
|
||||
@Test void rejectsMixedDestinations() {
|
||||
var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS");
|
||||
when(repository.lines(1L)).thenReturn(List.of(line, other));
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock);
|
||||
}
|
||||
|
||||
/** 库存落账失败时不推进计划状态和运输交接。 */
|
||||
@Test void doesNotAdvanceWhenStockInFails() {
|
||||
doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
|
||||
assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
assertEquals("PENDING_RECONCILIATION", plan.getStatus());
|
||||
verify(dispatch, never()).complete(anyList(), anyString(), any());
|
||||
verify(repository, never()).insert(any(InboundPdaOperation.class));
|
||||
}
|
||||
|
||||
/** 客户归属不匹配时不触发任何库存或运输操作。 */
|
||||
@Test void rejectsWrongCustomer() {
|
||||
when(repository.findPlan("100", "IP-1")).thenReturn(null);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
|
||||
verifyNoInteractions(stock, dispatch);
|
||||
}
|
||||
|
||||
/** 当前实收值随请求一起参与版本及幂等校验。 */
|
||||
private ConfirmInboundShortageRequest request(int version, int received) {
|
||||
return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱");
|
||||
}
|
||||
}
|
||||
+28
@@ -324,6 +324,34 @@ class InboundStockReceiptServiceTest {
|
||||
List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10)));
|
||||
}
|
||||
|
||||
/** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */
|
||||
@Test void exactSkuShortageStocksOnlyActualPackages() {
|
||||
Fixture f = fixture(21, 21, 20, 210);
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20)));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any()))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false));
|
||||
service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true);
|
||||
var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
|
||||
assertEquals(20, request.getValue().packages());
|
||||
assertEquals(21, f.detail.getPackageCount());
|
||||
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
|
||||
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
/** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */
|
||||
@Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() {
|
||||
Fixture f = fixture(21, 21, 20, 210);
|
||||
f.detail.setPackageCount(10);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110)));
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
|
||||
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task,
|
||||
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true));
|
||||
verify(inventory, never()).stockIn(any(), any(), any());
|
||||
}
|
||||
|
||||
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
||||
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
|
||||
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */
|
||||
class ShortageReceiptEvidencePersistenceTest {
|
||||
/** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */
|
||||
@Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)");
|
||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder(
|
||||
new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1));
|
||||
assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty();
|
||||
f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'");
|
||||
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21));
|
||||
}
|
||||
|
||||
/** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */
|
||||
@Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception {
|
||||
var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')");
|
||||
f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2);
|
||||
f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22");
|
||||
f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20");
|
||||
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
|
||||
assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero();
|
||||
}
|
||||
|
||||
/** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */
|
||||
@Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1);
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero();
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero();
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0");
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')");
|
||||
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final JdbcTemplate db;
|
||||
final ReceivingSkuReceiptMapper skus;
|
||||
final DispatchSourceMapper dispatch;
|
||||
|
||||
/** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */
|
||||
Fixture() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -35,6 +35,8 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
assertEquals("FAILED",batch.appointmentStatus());var originalB=store.plan(batch.batchKey(),"B");
|
||||
var session=context.getBean(SqlSessionTemplate.class);session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
|
||||
var mapper=spy(session.getMapper(AppointmentWorkDispatchMapper.class));
|
||||
// 本场景尚未扫码;保持真实已拣为空,不能借预排数量伪造出库事实。
|
||||
doReturn(List.of()).when(mapper).pickedPhysical(anyLong(),anyString());
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id());
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
|
||||
|
||||
+14
-5
@@ -59,7 +59,13 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:appointment-dispatch-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT,destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))");
|
||||
// 已收货来源读取库存余额,已拣来源独立读取扫码归属,不能退回计划数量代替。
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(40),pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(40),inbound_code VARCHAR(128))");
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),inbound_code VARCHAR(128),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40),order_no VARCHAR(40))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(40),dispatch_item_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(pick_id BIGINT,customer_id VARCHAR(40),order_id BIGINT,packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)");
|
||||
@@ -97,7 +103,11 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
}
|
||||
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
|
||||
private void seed(long item,String customer,long order,int count,String destination) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count);
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?,?,'ONT8')",item,customer,item+100,count,destination);
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,0,0)",item,customer,item+1000,item,count);
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(?,?,?)",item,customer,"IN-"+order);
|
||||
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=?",Integer.class,order)==0)
|
||||
jdbc.update("INSERT INTO ll_order VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count);
|
||||
@@ -163,7 +173,7 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
jdbc.update("UPDATE ll_inbound_fba_appointment SET version=3 WHERE id=1");assertEquals(3L,binding.candidateBatches(inputs).get(inputs.get(0)).appointmentBatches().get(0).recordVersion());
|
||||
}
|
||||
@Test void twoDispatchesCannotConsumeTheSameBatchQuotaConcurrently() throws Exception {
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80,'FBA','ONT8')");
|
||||
var start=new CyclicBarrier(2);var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var results=new ArrayList<Future<Boolean>>();
|
||||
@@ -175,6 +185,7 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
@Test void lateConfirmedAppointmentCanBindBothExistingPalletsWithoutReplacingCargo() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET package_count=40 WHERE id=11");
|
||||
jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=40,counted_package_count=40 WHERE pallet_id=111");
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=40 WHERE id=11");
|
||||
seed(12,"101",1,40,"FBA");
|
||||
var original=jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
|
||||
tx.executeWithoutResult(s->bind(11,12));
|
||||
@@ -201,8 +212,6 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
}
|
||||
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
|
||||
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),order_no VARCHAR(40),inbound_code VARCHAR(40))");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(1,'101','WC-1','IN-1'),(2,'202','WC-2','IN-2'),(3,'303','WC-3','IN-3')");
|
||||
tx.executeWithoutResult(s->bind(11));
|
||||
var identity=new CandidatePhysical("101",111L);
|
||||
var option=binding.allocationOptions(1,List.of(identity)).get(identity).get(0);
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ class LastlegOrderNoGeneratorTest {
|
||||
@Test void routesEveryOperationalTypeToItsDailySequence() {
|
||||
var generator=TestDocumentNumbers.orders();
|
||||
Map.ofEntries(Map.entry("LASTLEG","WC"),Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
|
||||
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
|
||||
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),Map.entry("OUTBOUND_TASK","PDA"),
|
||||
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"),
|
||||
Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"),
|
||||
Map.entry("RETURN_RECEIPT","RR"),Map.entry("RETURN_BATCH","RB"),Map.entry("RETURN_PACKING_PLAN","HX"))
|
||||
|
||||
+22
@@ -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()));
|
||||
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(){
|
||||
EventEnvelope source=event(); JsonNode node=v2Payload(source);
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
|
||||
+2
@@ -33,6 +33,8 @@ class OrderWorkflowFactMapperTest {
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
|
||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue());
|
||||
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
|
||||
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
|
||||
|
||||
@@ -459,6 +459,7 @@ export function parseArguments(argv) {
|
||||
waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1),
|
||||
prepareDropshipManualCount: values.get('prepare-dropship-manual-count') === 'true',
|
||||
prepareMeasurementReview: values.get('prepare-measurement-review') === 'true',
|
||||
verifyNonIsoContainer: values.get('verify-non-iso-container') === 'true',
|
||||
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
|
||||
routeMode: values.get('route-mode') || 'fixed',
|
||||
quiet: values.get('quiet') === 'true',
|
||||
@@ -495,6 +496,11 @@ export function validateExecutionOptions(options) {
|
||||
if (options.environment && !['test', 'gray'].includes(options.environment)) throw new Error('Only test or explicit gray acceptance is supported')
|
||||
if (options.f40TaxMode && !['BUY_DECLARATION', 'TAX_REFUND'].includes(options.f40TaxMode)) throw new Error('f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND')
|
||||
if (options.prepareDomesticPdaH5 && (options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'phase5-root-fulfillment')) throw new Error('prepare-domestic-pda-h5 仅允许单独指定 phase5-root-fulfillment')
|
||||
if (options.verifyNonIsoContainer && (options.environment !== 'test'
|
||||
|| options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'phase3-container-loading'
|
||||
|| options.verifyPalletLocationGuide)) {
|
||||
throw new Error('verify-non-iso-container 仅允许 TEST 单独柜号链,不能混用库位块删除探针')
|
||||
}
|
||||
if (options.prepareMeasurementReview && (options.environment !== 'test'
|
||||
|| options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'inbound-measurement'
|
||||
|| options.waitSplitCount !== 1 || options.prepareDomesticPdaH5 || options.prepareDropshipManualCount)) {
|
||||
|
||||
@@ -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-container-loading` 使用非 ISO 柜号 `JZXH10231043` 验证关联与预装确认,PDA 领取前再纠正为本轮 `TEST-…-NOISO` 柜号,继续完成拣货、五类合成装柜证据及装柜完成,保留空号拒绝和作业后禁改校验;不修改既有实际作业单。仅验证柜号链时,从总入口使用 `--modules phase3-container-loading --verify-non-iso-container true`,跳过跨渠道旁路分配和移除并明确记录该覆盖缺口;不添加整票库位块删除探针。跨服务接收须按本轮来源柜及最终柜号另行回查,不能仅凭 PDA 完成视为海外消费通过。
|
||||
|
||||
部署后需要定向验证该完整链时,模块顺序固定为:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -178,7 +178,7 @@ function server(fault = '') {
|
||||
pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] })
|
||||
}
|
||||
if (method === 'PUT' && path === D) {
|
||||
assert.equal(body.publishLooseboardDemand, detail.publishLooseboardDemand)
|
||||
assert.equal(Object.hasOwn(body, 'publishLooseboardDemand'), false)
|
||||
if (detail.stage !== 'BEFORE_LOADING') {
|
||||
if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount--
|
||||
return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派')
|
||||
|
||||
@@ -175,7 +175,6 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
maintenance.destinationAppointments = [...appointments.values()]
|
||||
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
|
||||
maintenance,
|
||||
publishLooseboardDemand: before.publishLooseboardDemand,
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] },
|
||||
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt,
|
||||
fileIds: [proof.reviewPhotos.DRIVER.fileId], photoFileIds: [proof.reviewPhotos.DRIVER_PHOTO.fileId] } },
|
||||
|
||||
@@ -175,7 +175,8 @@ export async function run(context) {
|
||||
if (!selectedChannel) return
|
||||
const channelName = selectedChannel.channelName
|
||||
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 bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
||||
context.assert(MODULE, '放舱订舱使用真实启用供应商', null,
|
||||
@@ -237,7 +238,10 @@ export async function run(context) {
|
||||
|
||||
const channelIsolation = selectPlatformChannelIsolationPair(
|
||||
context.createdResources?.platformLogisticsChannels, context.marker, warehouseId, channelCode)
|
||||
if (!channelIsolation.pair) {
|
||||
if (context.options?.verifyNonIsoContainer) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '跨渠道分配及移除',
|
||||
'本轮仅验证非 ISO 柜号履约链,不创建或删除旁路分配', 'out-of-scope', { blocking: false })
|
||||
} else if (!channelIsolation.pair) {
|
||||
context.addStep(MODULE, '两个真实有效渠道的柜次隔离 fixture', null, false,
|
||||
`${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS)
|
||||
} else {
|
||||
@@ -638,7 +642,8 @@ export async function run(context) {
|
||||
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
|
||||
beforeReplacementSnapshotResponse, '', OPTIONS)
|
||||
const replacementContainerNo = iso6346ContainerNumber(`${context.marker}-replacement`)
|
||||
// 纠错后仍保留非 ISO 格式,确保完成及跨服务事件不会重新加回校验。
|
||||
const replacementContainerNo = `TEST-${context.marker}-NOISO`.slice(0, 64)
|
||||
const replacementResponse = await request(context, 'PATCH',
|
||||
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
|
||||
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { controlledPhotoDownload } from './phase3-container-overview.mjs'
|
||||
import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
const overviewOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml')
|
||||
@@ -24,6 +25,18 @@ const phase3LoadingAcceptance = read('phase3-loading.mjs')
|
||||
const truckAcceptance = read('truck-appointments.mjs')
|
||||
const matrix = read('../../../docs/国内货柜履约/归档/货柜管理全链验收矩阵-2026-08-13.md')
|
||||
|
||||
test('non-ISO HTTP scope excludes unrelated deletion probes and keeps non-ISO completion facts', () => {
|
||||
const options = parseArguments(['--modules', 'phase3-container-loading', '--verify-non-iso-container', 'true',
|
||||
'--gateway', 'https://acceptance.invalid', '--username', 'test', '--password', 'test',
|
||||
'--pda-username', 'pda', '--pda-password', 'test'])
|
||||
assert.equal(validateExecutionOptions(options).verifyNonIsoContainer, true)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, verifyPalletLocationGuide: true }), /删除探针/)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, requestedModules: ['phase3-loading'] }), /单独柜号链/)
|
||||
assert.match(containerLoadingAcceptance, /const realContainerNo = 'JZXH10231043'/)
|
||||
assert.match(containerLoadingAcceptance, /const replacementContainerNo = `TEST-\$\{context.marker\}-NOISO`/)
|
||||
assert.match(containerLoadingAcceptance, /if \(context.options\?\.verifyNonIsoContainer\)[\s\S]*?addCoverageGap[\s\S]*?else if \(!channelIsolation.pair\)/)
|
||||
})
|
||||
|
||||
test('container acceptance reads the canonical domestic workbench route', () => {
|
||||
assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/)
|
||||
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \}/)
|
||||
|
||||
Reference in New Issue
Block a user