From 155dd9d77d871376c5906655b4d2246c9e8f16d6 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 19:24:49 +0800
Subject: [PATCH 01/63] feat(firstleg): align simulated containers with channel
groups
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 6 ++
firstleg/AGENTS.md | 2 +-
.../SmartLoadingChannelGroupCatalog.java | 64 +++++++++++++++++++
.../baiyue/firstleg/dto/ContainerDtos.java | 2 +-
.../baiyue/firstleg/dto/SmartLoadingDtos.java | 2 +-
.../entity/ContainerBookingEntity.java | 1 +
.../mapper/SmartLoadingCandidateMapper.java | 8 +--
.../oceanbooking/dto/OceanBookingDtos.java | 2 +-
.../mapper/OceanBookingMapper.java | 8 ++-
.../repository/OceanBookingRepository.java | 4 +-
.../service/OceanBookingService.java | 6 +-
.../repository/SmartLoadingRepository.java | 4 +-
.../firstleg/service/ContainerService.java | 16 +++--
.../firstleg/service/SmartLoadingService.java | 34 +++++-----
.../db/phase3-channel-container-migration.sql | 4 ++
.../db/phase3-smart-loading-schema.sql | 1 +
.../main/resources/openapi/firstleg-v1.yaml | 20 +++---
.../resources/openapi/ocean-bookings-v1.yaml | 5 +-
.../SmartLoadingChannelGroupCatalogTest.java | 17 +++++
...ormOceanBookingControllerContractTest.java | 2 +-
.../OceanBookingMapperContractTest.java | 4 +-
.../service/OceanBookingServiceTest.java | 16 +++--
.../service/ContainerServiceTest.java | 34 +++++-----
.../service/SmartLoadingServiceTest.java | 13 ++--
scripts/agents-index-contract.ps1 | 2 +-
25 files changed, 192 insertions(+), 85 deletions(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 31f1e1a75..847dae411 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -5060,6 +5060,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
+ channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -5854,6 +5855,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
+ OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
@@ -16397,6 +16399,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
+ ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
+ END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF;
@@ -16453,6 +16458,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
+ OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index 3e6de5460..a301acdd1 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -85,7 +85,7 @@
## 国内货柜履约装柜与 PDA 资源结算
-- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `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` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `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` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java
new file mode 100644
index 000000000..0e07eefbe
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java
@@ -0,0 +1,64 @@
+package com.baiyue.firstleg.domain;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/** Stable planning groups used by simulated containers and smart-loading filters. */
+public final class SmartLoadingChannelGroupCatalog {
+ private SmartLoadingChannelGroupCatalog() {}
+
+ public static final List GROUPS = List.of(
+ group("MATSON_CLX", "美森正班", "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK", "ZBYHWK", "ZBYHP"),
+ group("MATSON_MAX", "美森加班", "MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL", "BYHK", "BYHWK", "BYHP"),
+ group("EXX", "EXX", "EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX"),
+ group("HEDE", "合德", "HEDE_TRUCK", "HEDE_PARCEL", "HDHK", "HDHWK", "HDHP"),
+ group("OA_LA", "洛杉矶普船", "OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL", "PCHK", "PCHWK", "PCKK", "PCHP"),
+ group("OA_CHICAGO", "芝加哥普船", "OA_CHICAGO_RAIL", "CPHT"),
+ group("OA_HOUSTON", "休斯顿普船", "OA_HOUSTON_TRUCK", "HPHK"),
+ group("OA_SAVANNAH", "萨凡纳普船", "OA_SAVANNAH_TRUCK", "SPHK"),
+ group("OA_NEW_YORK_TRUCK", "纽约普船海卡", "OA_NEW_YORK_TRUCK", "NPHK", "NPHWK")
+ );
+
+ private static final Map BY_CODE = indexByCode();
+ private static final Map BY_CHANNEL = indexByChannel();
+
+ public static Definition require(String value) {
+ Definition result = BY_CODE.get(normalize(value));
+ if (result == null) throw new IllegalArgumentException("不支持的渠道分组: " + value);
+ return result;
+ }
+
+ public static Definition resolveChannel(String channelCode) {
+ return BY_CHANNEL.get(normalize(channelCode));
+ }
+
+ public static boolean contains(String groupCode, String channelCode) {
+ Definition group = BY_CODE.get(normalize(groupCode));
+ return group != null && group.channelCodes().contains(normalize(channelCode));
+ }
+
+ private static Definition group(String code, String name, String... channelCodes) {
+ return new Definition(code, name, Set.of(channelCodes));
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private static Map indexByCode() {
+ Map result = new LinkedHashMap<>();
+ GROUPS.forEach(group -> result.put(group.code(), group));
+ return Map.copyOf(result);
+ }
+
+ private static Map indexByChannel() {
+ Map result = new LinkedHashMap<>();
+ GROUPS.forEach(group -> group.channelCodes().forEach(channel -> result.put(channel, group)));
+ return Map.copyOf(result);
+ }
+
+ public record Definition(String code, String name, Set channelCodes) {}
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java
index b20580354..b9ee1f536 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java
@@ -83,7 +83,7 @@ public final class ContainerDtos {
) {}
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
- public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
+ public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelGroupCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
index acca35f80..939eff487 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java
@@ -49,7 +49,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
- Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
+ Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java
index 6f6244d5c..1901cacff 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java
@@ -24,6 +24,7 @@ public class ContainerBookingEntity {
private String sourceType;
private String recordStatus;
private Long mergedIntoContainerBookingId;
+ private String channelGroupCode;
private String channelCode;
private String channelName;
private Long oceanBookingId;
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
index e85a6daa8..1bea8b781 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java
@@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
""")
Page selectLinkCandidates(Page page,
- @Param("query") LinkCandidateQuery query);
+ @Param("query") LinkCandidateQuery query,
+ @Param("channelCodes") java.util.Set channelCodes);
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java
index e6c8f1013..b5f5bb663 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
public PageResult page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
- public PageResult linkCandidates(LinkCandidateQuery query) {
- return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
+ public PageResult linkCandidates(LinkCandidateQuery query, java.util.Set channelCodes) {
+ return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java
index a08d56de7..14163fa4d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.client.WarehousePartnerClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
+import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -71,8 +72,11 @@ public class OceanBookingService {
}
public PageResult linkCandidates(LinkCandidateQuery query) {
+ SmartLoadingChannelGroupCatalog.Definition group;
+ try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
+ catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
- query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
+ group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes());
}
public List channels(long originWarehouseId) {
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
index 5ebe83126..26b223597 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java
@@ -49,9 +49,9 @@ public class SmartLoadingRepository {
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
}
public PageResult workbenchContainers(WorkbenchContainerQuery query,
- Set actualChannels) {
+ Set channelGroups) {
return PageResult.from(candidates.workbenchContainers(
- new Page<>(query.page(), query.size()), query, actualChannels));
+ new Page<>(query.page(), query.size()), query, channelGroups));
}
public List channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java
index 4fbae5f56..c2565a9ff 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
+import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
@@ -55,13 +56,16 @@ public class ContainerService {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
- ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
- .filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
- .orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
+ SmartLoadingChannelGroupCatalog.Definition channelGroup;
+ try { channelGroup = SmartLoadingChannelGroupCatalog.require(request.channelGroupCode()); }
+ catch (IllegalArgumentException exception) { throw conflict(exception.getMessage()); }
+ boolean activeGroup = priceBooks.channels(versionId, warehouse.id()).stream()
+ .map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
+ if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
- value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
+ value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -85,9 +89,9 @@ public class ContainerService {
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
- || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
+ || !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
- throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
+ throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致");
}
try {
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
index 5806840b8..3e8b1e67c 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
+import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.entity.*;
@@ -42,17 +43,6 @@ import java.util.*;
@Service
public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50");
- private static final Map> WORKBENCH_CHANNEL_GROUPS = Map.of(
- "MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
- "MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
- "EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
- "HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
- "OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
- "OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
- "OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
- "OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
- "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
- );
@Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient;
@@ -102,7 +92,7 @@ public class SmartLoadingService {
public List candidates(long bookingId, long warehouseId) {
ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
- if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
+ if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
List values = candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
@@ -266,7 +256,7 @@ public class SmartLoadingService {
&& query.createdStart().isAfter(query.createdEnd())) {
throw validation("创建开始日期不能晚于结束日期");
}
- return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
+ return repository.workbenchContainers(query, normalizedGroups(query.channelGroupCodes()));
}
private List inboundCandidates(List details) {
@@ -384,13 +374,17 @@ public class SmartLoadingService {
private static Set actualChannels(List groups) {
Set actual = new LinkedHashSet<>();
for (String value : groups) {
- String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
- Set mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
- if (mapped == null) throw validation("不支持的渠道分组: " + value);
- actual.addAll(mapped);
+ try { actual.addAll(SmartLoadingChannelGroupCatalog.require(value).channelCodes()); }
+ catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
}
return actual;
}
+ private static Set normalizedGroups(List groups) {
+ return groups.stream().map(value -> {
+ try { return SmartLoadingChannelGroupCatalog.require(value).code(); }
+ catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
+ }).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+ }
@Transactional(readOnly = true)
public Plan draftByBooking(long bookingId) {
@@ -1689,8 +1683,10 @@ public class SmartLoadingService {
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
- return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
- && booking.getChannelCode().equals(candidate.channelCode());
+ SmartLoadingChannelGroupCatalog.Definition candidateGroup =
+ SmartLoadingChannelGroupCatalog.resolveChannel(candidate.channelCode());
+ return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
+ && booking.getChannelGroupCode().equals(candidateGroup.code());
}
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql
index 253376b8c..c7ca8bad3 100644
--- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql
+++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql
@@ -34,6 +34,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
+ ALTER TABLE fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
+ END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF;
@@ -93,6 +96,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
+ OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
index 25e445f75..45ae823fe 100644
--- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
+ channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index 8c9a4e852..f3389f8c6 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -2174,7 +2174,7 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
- required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
+ required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
@@ -2185,8 +2185,9 @@ components:
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
- channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
- channelName: {type: string, maxLength: 128, nullable: true}
+ channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 模拟柜创建时确定的九类计划渠道分组,关联真实订舱后继续保留}
+ channelCode: {type: string, maxLength: 64, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
+ channelName: {type: string, maxLength: 128, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -2444,8 +2445,8 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer:
type: object
- required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
- channelName, containerType, capacityCbm, maxWeightKg, planStatus, version]
+ required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
+ containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号}
@@ -2453,8 +2454,9 @@ components:
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string}
- channelCode: {type: string}
- channelName: {type: string}
+ channelGroupCode: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜匹配使用的计划渠道分组}
+ channelCode: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
+ channelName: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
containerType: {type: string}
capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}
maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}
@@ -2706,9 +2708,9 @@ components:
reason: {type: string, maxLength: 500}
ContainerSimulationCreateRequest:
type: object
- required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg]
+ required: [channelGroupCode, warehouseId, containerType, capacityCbm, maxWeightKg]
properties:
- channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道}
+ channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组}
warehouseId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
index 1ef8cff2c..f0c12cb69 100644
--- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
+++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
@@ -107,7 +107,8 @@ paths:
summary: Page released booking requirement candidates for one container
description: >-
Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates:
- RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType.
+ RELEASED, remainingQuantity greater than zero, exact warehouseId and containerType, and a concrete
+ booking channel belonging to channelGroupCode.
Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity
counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one
bounded set query; booking detail calls are not used. An empty result is a normal successful page.
@@ -115,7 +116,7 @@ paths:
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
+ - {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}}
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java
new file mode 100644
index 000000000..100d3dd35
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java
@@ -0,0 +1,17 @@
+package com.baiyue.firstleg.domain;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SmartLoadingChannelGroupCatalogTest {
+ @Test
+ void exposesNinePlanningGroupsAndMapsBothExxBookingChannels() {
+ assertThat(SmartLoadingChannelGroupCatalog.GROUPS).hasSize(9);
+ assertThat(SmartLoadingChannelGroupCatalog.require("EXX").channelCodes())
+ .contains("EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX");
+ assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_TRUCK")).isTrue();
+ assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_RAIL_NY")).isTrue();
+ assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "MATSON_CLX_TRUCK")).isFalse();
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java
index ac8b24244..c58a9e2cf 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java
@@ -80,7 +80,7 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml)
.contains("/api/platform/v1/ocean-bookings/link-candidates:")
.contains("x-permission: platform:firstleg:ocean-booking:list")
- .contains("warehouseId", "channelCode", "containerType")
+ .contains("warehouseId", "channelGroupCode", "containerType")
.contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity")
.contains("Capacity and max weight are derived display snapshots and never eligibility fields")
.contains("Returns only linkable requirement candidates")
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java
index 622b9844c..156feea9c 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java
@@ -49,12 +49,12 @@ class OceanBookingMapperContractTest {
@Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
- com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
+ com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("booking.status='RELEASED'")
.contains("booking.warehouse_id=#{query.warehouseId}")
- .contains("booking.channel_code=#{query.channelCode}")
+ .contains("(List.of(candidate), 1, 20, 1, 1));
+ when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult result = service.linkCandidates(new LinkCandidateQuery(701L,
- " SEA-US ", " 40HC ", null, null));
+ " MATSON_CLX ", " 40HC ", null, null));
assertThat(result.records()).containsExactly(candidate);
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
- verify(repository).linkCandidates(query.capture());
- assertThat(query.getValue().channelCode()).isEqualTo("SEA-US");
+ @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class);
+ verify(repository).linkCandidates(query.capture(), channels.capture());
+ assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX");
assertThat(query.getValue().containerType()).isEqualTo("40HC");
assertThat(query.getValue().page()).isEqualTo(1L);
assertThat(query.getValue().size()).isEqualTo(20L);
+ assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK");
verify(repository, never()).find(anyLong());
verify(repository, never()).requirements(anyLong());
verify(repository, never()).containers(anyLong());
@@ -349,15 +351,15 @@ class OceanBookingServiceTest {
@Test
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
- when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
+ when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
PageResult result = service.linkCandidates(
- new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null));
+ new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null));
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(result.pages()).isZero();
- verify(repository, times(1)).linkCandidates(any());
+ verify(repository, times(1)).linkCandidates(any(), anySet());
assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
.doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode");
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java
index 09e195623..b173ab652 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java
@@ -80,7 +80,9 @@ class ContainerServiceTest {
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
- assertThat(saved.getChannelName()).isEqualTo("美西海运");
+ assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
+ assertThat(saved.getChannelCode()).isNull();
+ assertThat(saved.getChannelName()).isNull();
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
@@ -94,7 +96,7 @@ class ContainerServiceTest {
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest(
- "SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
+ "MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class)
@@ -202,10 +204,10 @@ class ContainerServiceTest {
container.setPlanId(501L);
container.setPlanNo("LP-260818-0001");
container.setContainerNo(null);
- container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
+ container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
OceanBookingEntity booking = new OceanBookingEntity();
- booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
+ booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
when(repository.find(11L)).thenReturn(container, container);
@@ -311,13 +313,13 @@ class ContainerServiceTest {
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
ContainerBookingEntity first = booking("WAIT_LOADING", 2);
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L);
- first.setChannelCode("SEA-US"); first.setContainerType("40HC");
+ first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC");
ContainerBookingEntity second = booking("WAIT_LOADING", 4);
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L);
- second.setChannelCode("SEA-US"); second.setContainerType("40HC");
+ second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
- oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
+ oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(first);
@@ -360,19 +362,19 @@ class ContainerServiceTest {
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
- assertThreeFieldMismatch(102L, "SEA-US", "40HC");
+ assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC");
}
@Test
@DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() {
- assertThreeFieldMismatch(101L, "SEA-EU", "40HC");
+ assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC");
}
@Test
@DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() {
- assertThreeFieldMismatch(101L, "SEA-US", "45HC");
+ assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC");
}
@Test
@@ -415,7 +417,7 @@ class ContainerServiceTest {
String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
- container.setWarehouseId(101L); container.setChannelCode("SEA-US");
+ container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
@@ -429,7 +431,7 @@ class ContainerServiceTest {
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
- .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
+ .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@@ -437,11 +439,11 @@ class ContainerServiceTest {
private void stubLinkContext() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
- container.setWarehouseId(101L); container.setChannelCode("SEA-US");
+ container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
- oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
+ oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(container);
@@ -491,10 +493,10 @@ class ContainerServiceTest {
}
private static SimulationCreateRequest simulation() {
- return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
+ return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
}
private static ChannelRow channel() {
- ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel;
+ ChannelRow channel = new ChannelRow(); channel.setChannelCode("MATSON_CLX_TRUCK"); channel.setChannelName("美西海运"); return channel;
}
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
index fbae44ef2..c465a7e8f 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java
@@ -171,11 +171,11 @@ class SmartLoadingServiceTest {
@Test
void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
- "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
+ "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
- "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
+ "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
when(repository.booking(99L)).thenReturn(booking());
@@ -515,7 +515,7 @@ class SmartLoadingServiceTest {
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
- 100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC",
+ 100L, "义乌仓", "MATSON_CLX", null, null, "40HC",
new BigDecimal("68"), new BigDecimal("28000"),
1L, "LP-1", "EDITING", 2);
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
@@ -530,8 +530,7 @@ class SmartLoadingServiceTest {
@SuppressWarnings("unchecked") ArgumentCaptor> channels =
ArgumentCaptor.forClass(java.util.Set.class);
verify(repository).workbenchContainers(eq(query), channels.capture());
- assertThat(channels.getValue()).containsExactlyInAnyOrder(
- "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL");
+ assertThat(channels.getValue()).containsExactly("MATSON_CLX");
}
@Test
@@ -1694,7 +1693,7 @@ class SmartLoadingServiceTest {
private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes,
String destinationCode) {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
- 100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
+ 100L, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null, null, null);
}
@@ -1771,7 +1770,7 @@ class SmartLoadingServiceTest {
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
- value.setChannelCode("FBA");
+ value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
return value;
diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1
index b595214c9..bc13ba95a 100644
--- a/scripts/agents-index-contract.ps1
+++ b/scripts/agents-index-contract.ps1
@@ -589,7 +589,7 @@ Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 T
$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义'
Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约'
-Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义'
+Assert-Contains $oceanBookingOpenApi 'booking channel belonging to channelGroupCode' '订舱关联候选筛选语义'
$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts')
$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue')
From c480f957c5b2b6030ea28dfe6db49017289a44e0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 19:27:09 +0800
Subject: [PATCH 02/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lastleg-service/AGENTS.md | 4 +-
scripts/agents-index-contract.ps1 | 111 ++++++++++++++----------------
2 files changed, 55 insertions(+), 60 deletions(-)
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index e97d75a18..4a6119858 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -9,6 +9,8 @@
## 快速索引
+人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
+
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
| 功能 | 入口 |
@@ -122,5 +124,5 @@
- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
-- 定向验证:`mvn -pl lastleg -am test`。
+- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1
index bc13ba95a..42596bc14 100644
--- a/scripts/agents-index-contract.ps1
+++ b/scripts/agents-index-contract.ps1
@@ -87,6 +87,13 @@ $overseasPdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/AGEN
$backendAgentsPath = Join-Path $backendRoot 'AGENTS.md'
$rootAgents = Read-RequiredText $rootAgentsPath
+$businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md'
+$businessIndex = Read-RequiredText $businessIndexPath
+Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口'
+Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表'
+Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表'
+Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在'
+Assert-Contains $businessIndex '## 常用说法与歧义分流' '业务歧义分流存在'
$adminAgents = Read-RequiredText $adminAgentsPath
$clientAgents = Read-RequiredText $clientAgentsPath
$pdaAgents = Read-RequiredText $pdaAgentsPath
@@ -104,7 +111,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口'
Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁'
-Assert-Contains $rootAgents '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
+Assert-Contains $businessIndex '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引'
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
@@ -124,29 +131,15 @@ Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作
Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁'
Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口'
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
-Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
-Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
-# 启用状态属于各环境当前登记事实;不得用测试分支的历史任务名称/ID强制重新启用本地清空表。
-$registeredTestRows = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|'))
-if ($registeredTestRows.Count -eq 0) {
- Assert-Contains $testTaskRegistry '不启用总控调度' '测试登记清空状态'
- Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项' '测试登记清空明示'
- Assert-Contains $testTaskRegistry '持续授权同步失效' '测试登记清空授权边界'
-} else {
- Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
- Assert-Contains $testTaskRegistry 'parent_task_id' '测试登记父任务字段'
- Assert-Contains $testTaskRegistry 'project_id' '测试登记项目字段'
- Assert-Matches $testTaskRegistry '(?m)^\|[^\r\n]*【总控】[^\r\n]*\|' '测试登记总控职责'
- $registeredIds = @($registeredTestRows | ForEach-Object { $_.Groups[1].Value })
- if (@($registeredIds | Select-Object -Unique).Count -ne $registeredIds.Count) {
- Add-Failure '测试登记存在重复 task_id'
- }
- $taskLines = @($testTaskRegistry -split '\r?\n' | Where-Object { $_ -match '^\|\s*`[0-9a-f-]{36}`\s*\|' })
- $controlLines = @($taskLines | Where-Object { $_.Contains('【总控】') })
- if ($controlLines.Count -ne 1) { Add-Failure '测试登记必须有且仅有一个总控' }
- foreach ($taskLine in $taskLines) {
- Assert-Contains $taskLine '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试任务项目隔离'
- }
+# 当前测试登记已明确清空;历史 projectId/task_id 不得成为恢复调度的依据。
+Assert-Contains $testTaskRegistry '当前无登记任务' '测试登记清空明示'
+Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则' '测试登记清空状态'
+Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度' '测试登记禁止历史派工'
+Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试登记清空授权边界'
+Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记不得隐式启用'
+$checked++
+if ($testTaskRegistry -match '(?m)^\|\s*`[0-9a-f-]{36}`\s*\|') {
+ Add-Failure '测试登记不得残留任务行'
}
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
@@ -164,7 +157,7 @@ foreach ($businessDocsEntry in @(
'docs/国内货柜履约/README.md',
'docs/海外仓业务/README.md'
)) {
- Assert-Contains $rootAgents $businessDocsEntry "根业务域入口 $businessDocsEntry"
+ Assert-Contains $businessIndex $businessDocsEntry "根业务域入口 $businessDocsEntry"
}
foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) {
Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry"
@@ -172,18 +165,18 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
-Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货路由'
+Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
-Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户资金账户路由'
+Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
-Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
+Assert-Contains $businessIndex '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
-Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
-Assert-Contains $rootAgents '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
-Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
+Assert-Contains $businessIndex '目的港码头拖车与还空预警' '根目的港码头拖车路由'
+Assert-Contains $businessIndex '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
+Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
@@ -209,7 +202,7 @@ $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md'
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
-Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
+Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界'
@@ -233,7 +226,7 @@ Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-
# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md')
-Assert-Contains $rootAgents '客户头程订单批量导入' '根批量下单快速路由'
+Assert-Contains $businessIndex '客户头程订单批量导入' '根批量下单快速路由'
Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口'
Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束'
Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
@@ -259,7 +252,7 @@ $orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/
$unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java')
$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java')
$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
-Assert-Contains $rootAgents '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
+Assert-Contains $businessIndex '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由'
Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由'
Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口'
@@ -272,7 +265,7 @@ Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order
Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等'
Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性'
Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示'
-Assert-Contains $rootAgents '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
+Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态'
Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
@@ -285,14 +278,14 @@ $fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/mai
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
-Assert-Contains $rootAgents '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
+Assert-Contains $businessIndex '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
-Assert-Contains $rootAgents '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
+Assert-Contains $businessIndex '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
@@ -309,7 +302,7 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
-Assert-Contains $rootAgents '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
+Assert-Contains $businessIndex '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
Assert-Contains $adminAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
@@ -325,9 +318,9 @@ $pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/sr
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
-Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
-Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
-Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
+Assert-Contains $businessIndex '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
+Assert-Contains $businessIndex '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
+Assert-Contains $businessIndex '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
@@ -338,7 +331,7 @@ Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询'
Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射'
-Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口'
+Assert-Contains $businessIndex '客户入库结果与头程费用确认' '根客户入库费用确认入口'
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
@@ -358,7 +351,7 @@ $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m
$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql')
$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
$clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue')
-Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
+Assert-Contains $businessIndex '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口'
Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择'
@@ -378,7 +371,7 @@ Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_i
Assert-Contains $adminWeightPanel 'row.locationCode' 'Admin 称重卡片库位消费'
Assert-Contains $adminWeightPanel 'putawayStatusLabel(row.slotStatus)' 'Admin 称重卡片槽位状态'
Assert-NotContains $adminWeightPanel 'class="putaway-guide"' 'Admin 已移除整票预留引导区'
-Assert-Contains $rootAgents '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
+Assert-Contains $businessIndex '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
Assert-Contains $firstlegAgents '`totalPackageCount/remainingPackageCount`' 'Firstleg 智能装柜暂估数量'
Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin 智能装柜暂估数量'
Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI'
@@ -397,7 +390,7 @@ $iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java
$adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue')
$clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue')
$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue')
-Assert-Contains $rootAgents '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
+Assert-Contains $businessIndex '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界'
Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界'
Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口'
@@ -411,7 +404,7 @@ Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机
Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示'
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml')
-Assert-Contains $rootAgents '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
+Assert-Contains $businessIndex '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口'
Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI'
Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约'
@@ -427,8 +420,8 @@ $adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-w
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java')
-Assert-Contains $rootAgents '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
-Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
+Assert-Contains $businessIndex '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
+Assert-Contains $businessIndex '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口'
Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界'
Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口'
@@ -465,7 +458,7 @@ Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
-Assert-Contains $rootAgents '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
+Assert-Contains $businessIndex '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界'
Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希'
Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希'
@@ -612,7 +605,7 @@ foreach ($overseasFulfillmentRoute in @(
foreach ($overseasFulfillmentRootEntry in @(
'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue'
)) {
- Assert-Contains $rootAgents $overseasFulfillmentRootEntry '根海外履约正式业务路由'
+ Assert-Contains $businessIndex $overseasFulfillmentRootEntry '根海外履约正式业务路由'
}
Assert-Contains $adminAgents '海外排车、出库与派送' 'Admin 海外出库派送入口'
Assert-Contains $adminAgents '海外异常退回与客户回访' 'Admin 海外异常回访入口'
@@ -704,8 +697,8 @@ $containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/s
$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts')
$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts')
$customsFileOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/customs-file-and-export-v1.yaml')
-Assert-Contains $rootAgents 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
-Assert-Contains $rootAgents 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
+Assert-Contains $businessIndex 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
+Assert-Contains $businessIndex 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
Assert-Contains $adminAgents 'components/ContainerCustomsPanel.vue' 'Admin 整柜报关入口'
Assert-Contains $adminAgents 'complete-all-buy' 'Admin 全买单柜入口'
Assert-Contains $clientAgents 'components/CustomsDocumentsDialog.vue' 'Client 退税报关资料入口'
@@ -747,7 +740,7 @@ Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
-Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
+Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
@@ -791,8 +784,8 @@ $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/s
$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java')
$financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java')
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
-Assert-Contains $rootAgents '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
-Assert-Contains $rootAgents '海外业务费用来源与客户财务' '根海外费用快速路由'
+Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
+Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由'
Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
@@ -827,16 +820,16 @@ Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event
Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递'
Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界'
Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界'
-Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货快速路由'
-Assert-Contains $adminAgents '入库单扣货、解除、凭证与历史' 'Admin 入库扣货快速路由'
+Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由'
+Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货快速路由'
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
-Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由'
+Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
-Assert-Contains $rootAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '根海外小 Token 边界'
+Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界'
Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界'
Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等'
Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影'
From 07b5f6e077b2982575337de3ebd76b809d09564c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 19:34:40 +0800
Subject: [PATCH 03/63] test(iam): align migration guide contract
---
.../iam/repository/CustomerProfileMigrationContractTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
index 48b2738bf..5fbd42587 100644
--- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
@@ -78,7 +78,7 @@ class CustomerProfileMigrationContractTest {
.contains("phase1-onboarding.sql")
.contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段")
.contains("全局 `MERCHANT_OWNER` 角色模板")
- .contains("测试环境只执行当前固定入口");
+ .contains("测试环境结构发布只执行全量固定入口");
}
@Test
From 2b73245c9607702496eb35b34c7f46f3905533ff Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 19:38:40 +0800
Subject: [PATCH 04/63] test(firstleg): align workbench channel group contract
---
.../repository/SmartLoadingCandidateMapperContractTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
index d45182895..10087cbc4 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java
@@ -180,7 +180,7 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("p.plan_status IN ('EDITING','LOADING')")
- .contains("b.channel_code IN")
+ .contains("b.channel_group_code IN")
.contains("fl_loading_plan_item filter_item")
.contains("fl_inbound_order filter_inbound")
.contains("filter_item.inbound_code LIKE")
From 69e80a736e8c6a3055700b239ba760248a9ef103 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 20:19:11 +0800
Subject: [PATCH 05/63] fix(sql): make domestic migration replay-safe
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 103 ++++++++++++++++--
.../db/phase3-smart-loading-schema.sql | 61 +++++++++--
...ustomerDimensionConfigSqlContractTest.java | 3 +
.../Phase3FinalUnifiedSqlContractTest.java | 3 +-
.../SmartLoadingSqlContractTest.java | 14 ++-
5 files changed, 154 insertions(+), 30 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 847dae411..776b0e0b1 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -5485,6 +5485,7 @@ BEGIN
DECLARE source_exists INT DEFAULT 0;
DECLARE source_total_columns INT DEFAULT 0;
DECLARE source_smart_columns INT DEFAULT 0;
+ DECLARE source_current_columns INT DEFAULT 0;
DECLARE source_inbound_columns INT DEFAULT 0;
DECLARE target_exists INT DEFAULT 0;
DECLARE target_total_columns INT DEFAULT 0;
@@ -5529,7 +5530,45 @@ BEGIN
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
- IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
+ SELECT COUNT(*) INTO source_current_columns
+ FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
+ (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
+ OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
+ OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
+ OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
+ OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
+ OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
+ );
+
+ IF source_total_columns = 13 AND source_current_columns = 13
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
+ AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
+ AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'idx_loading_pick_container'
+ AND ((seq_in_index = 1 AND column_name = 'container_no')
+ OR (seq_in_index = 2 AND column_name = 'task_status')
+ OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
+ AND (SELECT COUNT(*) FROM information_schema.key_column_usage
+ WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
+ AND referenced_column_name = 'id') = 1 THEN
+ SET source_exists = 1;
+ ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
@@ -5843,6 +5882,29 @@ DELIMITER ;
CALL repair_phase3_loading_pick_task_foreign_keys();
DROP PROCEDURE repair_phase3_loading_pick_task_foreign_keys;
+-- Existing deployments may predate simulated-container channel grouping. The
+-- update trigger below references this column, so upgrade it before compiling
+-- the trigger rather than waiting for the later channel-container data block.
+DROP PROCEDURE IF EXISTS phase3_add_container_channel_group;
+DELIMITER $$
+CREATE PROCEDURE phase3_add_container_channel_group()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_container_booking'
+ AND column_name = 'channel_group_code'
+ ) THEN
+ ALTER TABLE fl_container_booking
+ ADD COLUMN channel_group_code VARCHAR(64) NULL
+ COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留'
+ AFTER merged_into_container_booking_id;
+ END IF;
+END$$
+DELIMITER ;
+CALL phase3_add_container_channel_group();
+DROP PROCEDURE phase3_add_container_channel_group;
+
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_booking_before_update
@@ -6451,14 +6513,13 @@ FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
- 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
- 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
- 'platform:firstleg:loading-list:view')
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
@@ -6583,13 +6644,13 @@ BEGIN
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
- 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
+ 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
- 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
+ 'platform:firstleg:loading-list:list')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -6599,14 +6660,13 @@ BEGIN
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
- 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
- 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
- 'platform:firstleg:loading-list:view')) <> 28 THEN
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
@@ -13773,6 +13833,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID');
+CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id', '所属真实货柜ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID');
@@ -15955,6 +16016,24 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整';
END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_customer_charge_rule'
+ AND column_name = 'initialization_key'
+ AND character_maximum_length = 128) <> 1 THEN
+ SIGNAL SQLSTATE '45000'
+ SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化幂等字段未创建';
+ END IF;
+ IF (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg'
+ AND table_name = 'fl_customer_charge_rule'
+ AND index_name = 'idx_charge_rule_initialization'
+ AND non_unique = 1
+ AND ((seq_in_index = 1 AND column_name = 'tenant_id')
+ OR (seq_in_index = 2 AND column_name = 'initialization_key'))) <> 2 THEN
+ SIGNAL SQLSTATE '45000'
+ SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化索引未完整创建';
+ END IF;
IF EXISTS (
SELECT tenant.id
FROM baiyue_iam.iam_tenant tenant
diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
index 45ae823fe..8245dbde0 100644
--- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
+++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql
@@ -466,6 +466,7 @@ BEGIN
DECLARE source_exists INT DEFAULT 0;
DECLARE source_total_columns INT DEFAULT 0;
DECLARE source_smart_columns INT DEFAULT 0;
+ DECLARE source_current_columns INT DEFAULT 0;
DECLARE source_inbound_columns INT DEFAULT 0;
DECLARE target_exists INT DEFAULT 0;
DECLARE target_total_columns INT DEFAULT 0;
@@ -510,7 +511,45 @@ BEGIN
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
- IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
+ SELECT COUNT(*) INTO source_current_columns
+ FROM information_schema.columns
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
+ (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
+ OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
+ OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
+ OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
+ OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
+ OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
+ OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
+ OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
+ );
+
+ IF source_total_columns = 13 AND source_current_columns = 13
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
+ AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
+ AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
+ AND (SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND index_name = 'idx_loading_pick_container'
+ AND ((seq_in_index = 1 AND column_name = 'container_no')
+ OR (seq_in_index = 2 AND column_name = 'task_status')
+ OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
+ AND (SELECT COUNT(*) FROM information_schema.key_column_usage
+ WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
+ AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
+ AND referenced_column_name = 'id') = 1 THEN
+ SET source_exists = 1;
+ ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
@@ -1431,14 +1470,13 @@ FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
- 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
- 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
- 'platform:firstleg:loading-list:view')
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
@@ -1563,13 +1601,13 @@ BEGIN
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
- 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
+ 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
- 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
+ 'platform:firstleg:loading-list:list')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -1579,14 +1617,13 @@ BEGIN
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
- 'platform:firstleg:container:list', 'platform:firstleg:container:view',
+ 'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
- 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
+ 'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
- 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
- 'platform:firstleg:loading-list:view')) <> 28 THEN
+ 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java
index 783615014..aec810024 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java
@@ -115,6 +115,9 @@ class CustomerDimensionConfigSqlContractTest {
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("index_name = 'uk_customer_dimension_config_tenant_box'")
.contains("客户四箱规尺寸配置字段不完整")
+ .contains("客户默认计费规则初始化幂等字段未创建")
+ .contains("index_name = 'idx_charge_rule_initialization'")
+ .contains("客户默认计费规则初始化索引未完整创建")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java
index b84c6a04f..2d812a167 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java
@@ -188,7 +188,8 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
- .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'");
+ .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'")
+ .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id'");
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
index 909474e2d..5a024d6e5 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
@@ -18,14 +18,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingSqlContractTest {
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
private static final Set PHASE3_PERMISSIONS = Set.of(
- "platform:firstleg:container:list", "platform:firstleg:container:view",
+ "platform:firstleg:container:list",
"platform:firstleg:container:create", "platform:firstleg:container:edit",
"platform:firstleg:smart-loading:list",
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
"platform:firstleg:preload:list",
- "platform:firstleg:preload:view", "platform:firstleg:preload:edit",
+ "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
- "platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
+ "platform:firstleg:loading-list:list");
@Test
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
@@ -193,7 +193,7 @@ class SmartLoadingSqlContractTest {
}
@Test
- @DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权")
+ @DisplayName("模块脚本和统一脚本均校验11项权限与两个角色22项授权")
void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration());
assertPermissionContract(unifiedMigration());
@@ -238,6 +238,8 @@ class SmartLoadingSqlContractTest {
.doesNotContain("WITHDRAWN");
assertLoadingCompletionTrigger(migration());
assertLoadingCompletionTrigger(unifiedMigration());
+ assertThat(unifiedMigration().indexOf("CALL phase3_add_container_channel_group()"))
+ .isLessThan(unifiedMigration().indexOf("CREATE TRIGGER trg_container_booking_before_update"));
}
private static void assertLoadingCompletionTrigger(String sql) {
@@ -266,6 +268,8 @@ class SmartLoadingSqlContractTest {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
.contains("CALL migrate_legacy_loading_pick_task()")
+ .contains("source_total_columns = 13 AND source_current_columns = 13")
+ .contains("index_name = 'uk_loading_pick_task_booking'")
.contains("RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
@@ -371,7 +375,7 @@ class SmartLoadingSqlContractTest {
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
- assertThat(assertion).contains("<> 28 THEN");
+ assertThat(assertion).contains("<> 22 THEN");
}
private static Set permissions(String sql) {
From 47a70406f6425b4ffb71c5f90ca8e68c6bfe75e4 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 20:47:15 +0800
Subject: [PATCH 06/63] fix(acceptance): reuse initialized pallet fixtures
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 11 +++++-
scripts/admin-test-env-acceptance.mjs | 37 +------------------
scripts/admin-test-env-modules/README.md | 2 +-
.../inbound-measurement.mjs | 33 ++---------------
.../it-test-wait-split-bulk-contract.test.mjs | 8 ++--
.../phase3-container-loading.mjs | 16 +++++---
.../admin-test-env-modules/phase3-loading.mjs | 17 +--------
.../phase3-order-splitting.mjs | 4 +-
.../phase3-producer-chain-contract.test.mjs | 4 +-
.../smart-loading-channel-group.mjs | 14 +++++++
.../main/resources/db/phase2-pallet-seed.sql | 11 +++++-
...lWarehouseResourceSeedSqlContractTest.java | 3 ++
12 files changed, 65 insertions(+), 95 deletions(-)
create mode 100644 scripts/admin-test-env-modules/smart-loading-channel-group.mjs
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 776b0e0b1..d939bf45c 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -6988,7 +6988,8 @@ ON DUPLICATE KEY UPDATE
capacity_cbm = VALUES(capacity_cbm),
weight_kg = VALUES(weight_kg),
color = VALUES(color),
- remark = VALUES(remark);
+ remark = VALUES(remark),
+ deleted = 0;
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
@@ -7010,6 +7011,14 @@ BEGIN
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
+ IF (SELECT COUNT(*)
+ FROM wh_pallet
+ WHERE tenant_id = 0
+ AND deleted = 0
+ AND ((warehouse_id = 193000000000200001 AND pallet_code IN ('TP-001','YW-TP-002'))
+ OR (warehouse_id = 193000000000200002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓固定可复用托盘编码初始化不完整';
+ END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs
index a92d0ef0b..6e83c061d 100644
--- a/scripts/admin-test-env-acceptance.mjs
+++ b/scripts/admin-test-env-acceptance.mjs
@@ -1317,7 +1317,7 @@ export class AcceptanceRun {
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount',
- 'phase3LoadingIncrementalDetailCount', 'orderProfile', 'measurementPalletOverride']
+ 'phase3LoadingIncrementalDetailCount', 'orderProfile']
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}]))
@@ -1362,9 +1362,6 @@ export class AcceptanceRun {
: { forced: false, disabled: [] }
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
try {
- if (requireSharedOverflow) {
- this.context.measurementPalletOverride = await this.createPhase3FreshMeasurementPallet(warehouseId)
- }
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
} finally {
@@ -1397,7 +1394,6 @@ export class AcceptanceRun {
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
this.marker = incrementalMarker
this.context.phase3LoadingRequireSharedOverflow = false
- delete this.context.measurementPalletOverride
this.context.phase3LoadingIncrementalPackageCount = 2
this.context.phase3LoadingIncrementalDetailCount = 4
// orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that
@@ -1609,37 +1605,6 @@ export class AcceptanceRun {
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
}
- async createPhase3FreshMeasurementPallet(warehouseId) {
- const module = 'phase3-loading-fixture'
- const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
- const payload = {
- warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
- color: 'ORANGE', remark: `${this.marker}-inventory-snapshot-a0`,
- }
- const createResponse = await this.request('POST', '/api/platform/v1/pallets?tenantId=0', payload)
- const palletId = String(this.apiSuccess(module, '创建本轮库存快照专用称重托盘', createResponse,
- `warehouseId=${warehouseId}`, options) || '')
- if (!palletId) return null
-
- const detailResponse = await this.request('GET',
- `/api/platform/v1/pallets/${encodeURIComponent(palletId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
- const pallet = this.apiSuccess(module, '回读本轮库存快照专用称重托盘', detailResponse,
- `palletId=${palletId}`, options)
- const valid = String(pallet?.id || '') === palletId
- && String(pallet?.warehouseId || '') === String(warehouseId)
- && Boolean(String(pallet?.palletCode || '').trim())
- this.assert(module, '本轮专用称重托盘尚未复用历史占用身份', detailResponse, valid,
- `palletId=${palletId}, palletCode=${pallet?.palletCode || 'missing'}`, options)
- if (!valid) return null
-
- const fixture = {
- id: palletId, code: String(pallet.palletCode), warehouseId: String(warehouseId),
- marker: this.marker, retainedReason: '测量审核后成为正式库存占用,保留为开发验收证据',
- }
- this.createdResources.phase3LoadingFreshMeasurementPallet = fixture
- return fixture
- }
-
async listAvailableLocationsForA0(warehouseId, module, options) {
const locations = []
const seen = new Set()
diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md
index 11d777acc..201863e74 100644
--- a/scripts/admin-test-env-modules/README.md
+++ b/scripts/admin-test-env-modules/README.md
@@ -140,7 +140,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
-计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 通过平台托盘 API 创建本轮专用称重托盘,再交给通用测量模块;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库或调用内部 Warehouse 接口。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。批量待分柜入口仍只复用 `TP-001`/`YW-TP-002`,不创建托盘。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
+计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 与批量待分柜入口都只随机复用初始化的 `TP-001`/`YW-TP-002`,任一缺失即报告部署阻断,不调用托盘新增接口;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库、创建库位或调用内部 Warehouse 接口。模拟柜的 `channelGroupCode` 统一由 `smart-loading-channel-group.mjs` 根据订单渠道映射。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs
index 1326096c1..7ccb05bc8 100644
--- a/scripts/admin-test-env-modules/inbound-measurement.mjs
+++ b/scripts/admin-test-env-modules/inbound-measurement.mjs
@@ -30,13 +30,6 @@ function validPdf(response) {
async function reusablePallets(context, warehouseId) {
const cacheKey = asId(warehouseId)
- const override = context.context?.measurementPalletOverride
- if (asId(override?.warehouseId) === cacheKey && asId(override?.id)
- && String(override?.code || '').trim()) {
- context.assert(MODULE, '使用编排层提供的本轮专用称重托盘', null, true,
- `warehouseId=${warehouseId}, palletId=${asId(override.id)}`, OPTIONS)
- return [{ id: asId(override.id), code: String(override.code) }]
- }
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
@@ -64,29 +57,11 @@ async function reusablePallets(context, warehouseId) {
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
- `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=${exact ? 'not-needed' : 'create-marker-owned'}`, OPTIONS)
+ `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
if (!exact) {
- // A newly selected development warehouse may not have the historical fixture pallets.
- // Create one marker-owned pallet through the public platform API rather than coupling
- // measurement acceptance to another warehouse's seed data.
- const createResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
- warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
- color: 'ORANGE', remark: `${context.marker}-measurement-fixture`,
- })
- const createdId = asId(context.apiSuccess(MODULE, '创建本轮称重托盘', createResponse,
- `warehouseId=${warehouseId}`, OPTIONS))
- if (!createdId) return null
- const detailResponse = await context.request('GET',
- `/api/platform/v1/pallets/${encodeURIComponent(createdId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
- const created = context.apiSuccess(MODULE, '回读本轮称重托盘', detailResponse,
- `warehouseId=${warehouseId}, palletId=${createdId}`, OPTIONS)
- const valid = asId(created?.id) === createdId && asId(created?.warehouseId) === cacheKey
- && String(created?.palletCode || '').trim()
- context.assert(MODULE, '新建称重托盘归属当前仓库且编码完整', detailResponse, valid,
- `warehouseId=${warehouseId}, palletId=${createdId}, palletCode=${created?.palletCode || 'missing'}`, OPTIONS)
- if (!valid) return null
- pallets.push({ id: createdId, code: String(created.palletCode) })
- break
+ context.assert(MODULE, '固定称重托盘主数据完整', response, false,
+ `warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
+ return null
}
pallets.push({ id: asId(exact.id), code: palletCode })
}
diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs
index efd755ca0..6048ff9c7 100644
--- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs
+++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs
@@ -28,14 +28,14 @@ test('随机线路只固定义乌仓并从客户真实可计价候选选择', ()
assert.doesNotMatch(runner, /channel-name|channel-code/)
})
-test('称重优先复用固定托盘并允许为无基线仓创建本轮托盘', () => {
+test('称重只随机复用固定托盘且不创建新托盘', () => {
assert.match(measurement, /REUSABLE_PALLET_CODES = Object\.freeze\(\['TP-001', 'YW-TP-002'\]\)/)
assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/)
assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/)
assert.match(measurement, /随机复用原有称重托盘/)
- assert.match(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets\?tenantId=0'/)
- assert.match(measurement, /创建本轮称重托盘/)
- assert.match(measurement, /measurement-fixture/)
+ assert.match(measurement, /固定称重托盘主数据完整/)
+ assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/)
+ assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/)
})
test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => {
diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs
index 5b1fe75b8..174eb2f69 100644
--- a/scripts/admin-test-env-modules/phase3-container-loading.mjs
+++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs
@@ -3,6 +3,7 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-pr
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
+import { channelGroup } from './smart-loading-channel-group.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -208,7 +209,7 @@ export async function run(context) {
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
- channelCode, warehouseId, containerType: primaryRequirement.containerType,
+ channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse,
@@ -216,7 +217,8 @@ export async function run(context) {
const containerId = id(simulation?.id)
context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse,
Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
- && simulation?.oceanBookingId == null && simulation?.channelCode === channelCode
+ && simulation?.oceanBookingId == null && simulation?.channelGroupCode === channelGroup(channelCode)
+ && simulation?.channelCode == null
&& id(simulation?.warehouseId) === warehouseId,
`containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS)
if (!containerId) return
@@ -240,7 +242,8 @@ export async function run(context) {
} else {
const foreignChannel = channelIsolation.pair.foreign
const foreignContainerResponse = await request(context, 'POST', CONTAINERS, {
- channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType,
+ channelGroupCode: channelGroup(foreignChannel.channelCode), warehouseId,
+ containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
@@ -288,9 +291,10 @@ export async function run(context) {
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
- && containerDetail?.channelCode === channelCode
+ && containerDetail?.channelGroupCode === channelGroup(channelCode)
+ && containerDetail?.channelCode == null
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
- `planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
+ `planStatus=${containerDetail?.planStatus || 'missing'}, channelGroup=${containerDetail?.channelGroupCode || 'missing'}`, OPTIONS)
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
@@ -517,7 +521,7 @@ export async function run(context) {
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
- channelCode, warehouseId, containerType: primaryRequirement.containerType,
+ channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs
index 47d0eca4a..5c804fa85 100644
--- a/scripts/admin-test-env-modules/phase3-loading.mjs
+++ b/scripts/admin-test-env-modules/phase3-loading.mjs
@@ -1,6 +1,7 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
+import { channelGroup } from './smart-loading-channel-group.mjs'
import { createHash } from 'node:crypto'
const MODULE = 'phase3-loading'
@@ -135,20 +136,6 @@ function inventorySignature(snapshot) {
})))
}
-function channelGroup(channelCode) {
- const value = String(channelCode || '').toUpperCase()
- if (value.startsWith('EXX_')) return 'EXX'
- if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
- if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
- if (value.startsWith('HEDE_')) return 'HEDE'
- if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
- if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
- if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
- if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
- if (value === 'OA_NEW_YORK_TRUCK') return value
- throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
-}
-
function operationsSignature(operations) {
return JSON.stringify((operations || []).map((operation) => ({
id: id(operation?.id), operationId: operation?.operationId || null,
@@ -226,7 +213,7 @@ export async function run(context) {
maxWeightKg: 28000 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
- channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
index b57902ad2..b498c44c8 100644
--- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs
+++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
@@ -10,6 +10,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
import { ensureInitialCalculated } from './inbound-fees.mjs'
+import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -186,7 +187,8 @@ async function loadOneFragment(context, state) {
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, {
- channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
+ channelGroupCode: channelGroup(state.channelCode), warehouseId,
+ containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
index a08fa5af1..0c2b126a5 100644
--- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
+++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
@@ -434,7 +434,9 @@ test('container loading permits manual cross-channel allocation and keeps stable
'换柜号后按稳定计划身份回查 PDA 任务',
'换柜号不改变任务计划柜次柜次号和库存快照身份',
])
- assert.match(value, /containerDetail\?\.channelCode === channelCode/)
+ assert.match(value, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
+ assert.match(value, /simulation\?\.channelCode == null/)
+ assert.match(value, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
diff --git a/scripts/admin-test-env-modules/smart-loading-channel-group.mjs b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs
new file mode 100644
index 000000000..dacbfbdef
--- /dev/null
+++ b/scripts/admin-test-env-modules/smart-loading-channel-group.mjs
@@ -0,0 +1,14 @@
+export function channelGroup(channelCode) {
+ const value = String(channelCode || '').trim().toUpperCase()
+ if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX'
+ if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX'
+ if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'
+ if (value.startsWith('HEDE_') || ['HDHK', 'HDHWK', 'HDHP'].includes(value)) return 'HEDE'
+ if (value.startsWith('OA_LA_') || value === 'OA_PARCEL'
+ || ['PCHK', 'PCHWK', 'PCKK', 'PCHP'].includes(value)) return 'OA_LA'
+ if (value.startsWith('OA_CHICAGO_') || value === 'CPHT') return 'OA_CHICAGO'
+ if (value.startsWith('OA_HOUSTON_') || value === 'HPHK') return 'OA_HOUSTON'
+ if (value.startsWith('OA_SAVANNAH_') || value === 'SPHK') return 'OA_SAVANNAH'
+ if (['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'].includes(value)) return 'OA_NEW_YORK_TRUCK'
+ throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
+}
diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql
index 69c6aa626..d40c77f8b 100644
--- a/warehouse/src/main/resources/db/phase2-pallet-seed.sql
+++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql
@@ -16,7 +16,8 @@ ON DUPLICATE KEY UPDATE
capacity_cbm=VALUES(capacity_cbm),
weight_kg=VALUES(weight_kg),
color=VALUES(color),
- remark=VALUES(remark);
+ remark=VALUES(remark),
+ deleted=0;
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
@@ -38,6 +39,14 @@ BEGIN
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
+ IF (SELECT COUNT(*)
+ FROM wh_pallet
+ WHERE tenant_id=0
+ AND deleted=0
+ AND ((warehouse_id=190000000000000001 AND pallet_code IN ('TP-001','YW-TP-002'))
+ OR (warehouse_id=190000000000000002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓固定可复用托盘编码初始化不完整';
+ END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java
index 9e09034f1..e2dd761f1 100644
--- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java
+++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java
@@ -27,6 +27,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
}
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
+ String compactSql = sql.replaceAll("\\s+", "");
int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201");
int locationSeedEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_yiwu_location_seed", locationSeedStart);
String locationSeed = sql.substring(locationSeedStart, locationSeedEnd);
@@ -37,6 +38,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
.contains("0," + dongguanId + ",'DG-TP-002','木托盘'")
.contains("COUNT(DISTINCT pallet.pallet_type)")
.contains("国内作业仓木托盘、塑料托盘初始化不完整")
+ .contains("固定可复用托盘编码初始化不完整")
.doesNotContain("'胶托盘'")
.contains("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201")
.contains("ELSE 203 + zones.zone_index * 15 + slots.slot_number")
@@ -55,6 +57,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
.contains("'AVAILABLE'")
.doesNotContain("INSERT INTO wh_location_reservation")
.doesNotContain("INSERT INTO wh_location_occupancy");
+ assertThat(compactSql).contains("remark=VALUES(remark),deleted=0;");
assertThat(locationSeed)
.doesNotContain("status='AVAILABLE'")
.doesNotContain("status = 'AVAILABLE'")
From 7b35373da35e4cf5f19116f841743fd73ce3a742 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 20:47:23 +0800
Subject: [PATCH 07/63] fix(firstleg): enforce PDA task warehouse scope
---
.../com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java | 3 +++
.../repository/LoadingPickStableLookupContractTest.java | 5 ++++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java
index 614173ae9..9fa4e1c78 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java
@@ -49,6 +49,7 @@ public interface LoadingPickTaskMapper extends BaseMapper
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
+ AND booking.warehouse_id = #{warehouseId}
AND task.task_status = 'LOADING'
LIMIT 1
""")
@@ -58,12 +59,14 @@ public interface LoadingPickTaskMapper extends BaseMapper
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ + "AND booking.warehouse_id=#{warehouseId} "
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ + "AND booking.warehouse_id=#{warehouseId} "
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java
index 9342ba8a4..3c921d8ec 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java
@@ -15,10 +15,13 @@ class LoadingPickStableLookupContractTest {
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'")
+ .contains("booking.warehouse_id=#{warehouseId}")
.doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'")
+ .contains("booking.warehouse_id=#{warehouseId}")
.doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}",
- "booking.record_status = 'ACTIVE'").doesNotContain("task.container_no =");
+ "booking.record_status = 'ACTIVE'", "booking.warehouse_id = #{warehouseId}")
+ .doesNotContain("task.container_no =");
}
}
From 800ebb875f0c09f3b6c23e11beae463bf959f5c9 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 21:18:52 +0800
Subject: [PATCH 08/63] fix(acceptance): enforce immutable confirmed fees
---
.../service/CarrierScheduleQueryService.java | 11 ++++
.../firstleg/service/FbaPricingService.java | 13 ++--
.../CarrierScheduleQueryServiceTest.java | 20 +++++++
.../service/FbaPricingServiceTest.java | 5 +-
scripts/admin-test-env-acceptance.mjs | 13 ++--
.../coload-groups-contract.test.mjs | 2 +
.../admin-test-env-modules/coload-groups.mjs | 26 +++-----
...container-fee-allocation-contract.test.mjs | 1 +
.../firstleg-container-fee-allocation.mjs | 3 +-
...manual-state-loading-pda-contract.test.mjs | 1 +
.../manual-state-loading-pda.mjs | 3 +-
.../phase3-order-splitting-contract.test.mjs | 8 +++
.../phase3-order-splitting.mjs | 59 ++++++++++++++-----
...verseas-acceptance-entry-contract.test.mjs | 4 +-
14 files changed, 116 insertions(+), 53 deletions(-)
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java
index 5741edc80..7ec142ab3 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java
@@ -222,6 +222,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
+ requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -306,6 +307,16 @@ public class CarrierScheduleQueryService {
return false;
}
+ private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
+ LocalDateTime actualAt) {
+ if (actualAt == null) return;
+ CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
+ : CarrierScheduleNode.require(value.getLatestStandardNode());
+ if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) {
+ throw conflict("实际船节点必须按业务顺序向后推进");
+ }
+ }
+
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
index 7d5fcda01..e27119f62 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
@@ -98,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
- if (feeRevisionLocked(inbound, published)) {
- throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
+ if (feeRevisionLocked(published)) {
+ throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -490,8 +490,8 @@ public class FbaPricingService {
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
- if (feeRevisionLocked(inbound, current)) {
- throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
+ if (feeRevisionLocked(current)) {
+ throw conflict("客户已确认,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -613,9 +613,8 @@ public class FbaPricingService {
.contains(processStatus);
}
- private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
- return fee != null && fee.getCustomerConfirmedAt() != null
- && List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
+ private static boolean feeRevisionLocked(InboundFeeEntity fee) {
+ return fee != null && fee.getCustomerConfirmedAt() != null;
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java
index 0614e2eec..ddbf2e5fd 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java
@@ -474,6 +474,26 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin");
}
+ @Test
+ void manualActualFactRejectsCurrentOrEarlierBusinessNodeBeforeWritingFact() {
+ CarrierScheduleQueryEntity task = task(0, 3);
+ task.setStatus("READY"); task.setLatestStandardNode("AT_ORIGIN_PORT");
+ task.setLatestNodeObservedAt(NOW.minusHours(1));
+ when(repository.lock(1L)).thenReturn(task);
+
+ assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-same-1",
+ new ManualNodeRequest(3, "AT_ORIGIN_PORT", NOW, null, NOW,
+ "CNSZX", "深圳", "重复当前节点")))
+ .isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
+ assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-earlier-1",
+ new ManualNodeRequest(3, "DEPARTED_WAREHOUSE", NOW, null, NOW,
+ "CNSZX", "深圳", "补录更早节点")))
+ .isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
+
+ verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
+ verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
+ }
+
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
index b0b6aeebf..77b1c98a6 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
@@ -461,7 +461,6 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT");
- current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -531,10 +530,10 @@ class FbaPricingServiceTest {
}
@Test
- @DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
+ @DisplayName("客户确认后即拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
- inbound.setProcessStatus("LOADED");
+ inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs
index 6e83c061d..6444347f4 100644
--- a/scripts/admin-test-env-acceptance.mjs
+++ b/scripts/admin-test-env-acceptance.mjs
@@ -531,6 +531,7 @@ export class AcceptanceRun {
this.modules = []
this.scenarios = []
this.createdResources = {}
+ this.phase4ProducerAttempts = new Map()
this.deploymentBlockers = []
this.coverageGaps = []
this.context = {}
@@ -1466,7 +1467,9 @@ export class AcceptanceRun {
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) {
const matrixMarker = this.marker
- const variantMarker = `${matrixMarker}-P4-${label}`
+ const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1
+ this.phase4ProducerAttempts.set(label, attempt)
+ const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}`
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
const createdKeys = [
'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking',
@@ -1571,7 +1574,7 @@ export class AcceptanceRun {
const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => item?.destinationType === 'FBA'
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
- && String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
+ && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasStandardFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true)
}
@@ -1579,7 +1582,7 @@ export class AcceptanceRun {
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true)
}
const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
- .some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:'))
+ .some((item) => /-P4-SECONDARY(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasSecondaryProducer()) {
await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false)
}
@@ -1588,10 +1591,10 @@ export class AcceptanceRun {
const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA'
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
- && String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
+ && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS')
const awaitingVariants = containers.filter((item) => ['SECONDARY']
- .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`)))
+ .some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || ''))))
const shippingMatrixReady = dropshipReady?.shippingReady === true
&& Boolean(dropshipReady?.carrierScheduleQueryId)
&& standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId)
diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs
index 5a03ba02c..ee667f20e 100644
--- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs
+++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs
@@ -43,4 +43,6 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
assert.doesNotMatch(source, /push-preload/)
+ assert.match(source, /import \{ channelGroup \} from '\.\/smart-loading-channel-group\.mjs'/)
+ assert.equal((source.match(/channelGroupCode:\s*channelGroup\(/g) || []).length, 6)
})
diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs
index b183a2032..e8ef138b2 100644
--- a/scripts/admin-test-env-modules/coload-groups.mjs
+++ b/scripts/admin-test-env-modules/coload-groups.mjs
@@ -1,6 +1,7 @@
const MODULE = 'coload-groups'
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
+import { channelGroup } from './smart-loading-channel-group.mjs'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const PDA = '/api/platform/v1/first-leg/pda-loading'
@@ -10,19 +11,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) }
-function channelGroup(channelCode) {
- const value = String(channelCode || '').toUpperCase()
- if (value.startsWith('EXX_')) return 'EXX'
- if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
- if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
- if (value.startsWith('HEDE_')) return 'HEDE'
- if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
- if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
- if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
- if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
- if (value === 'OA_NEW_YORK_TRUCK') return value
- throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
-}
function code(response) { return id(response?.body?.code) || 'missing' }
function evidence(response, extra = '') {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
@@ -105,7 +93,7 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
- channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
@@ -131,7 +119,7 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
- channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
@@ -522,7 +510,7 @@ export async function run(context) {
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
- channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
+ channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
@@ -613,7 +601,7 @@ export async function run(context) {
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
- channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
@@ -659,7 +647,7 @@ export async function run(context) {
}
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
- channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
@@ -978,7 +966,7 @@ export async function run(context) {
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
evidence(uiGroupResponse), OPTIONS)
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
- channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs
index 01473bc7d..9ca244e90 100644
--- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs
+++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs
@@ -18,6 +18,7 @@ test('candidate plans only come from resources created by the current acceptance
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
}), ['101', '102', '103', '104'])
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
+ assert.match(source, /channelGroupCode:\s*channelGroup\(first\.channelCode\)/)
})
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs
index 86f22ab80..0343a9ff2 100644
--- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs
+++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs
@@ -2,6 +2,7 @@ const MODULE = 'firstleg-container-fee-allocation'
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
+import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
@@ -140,7 +141,7 @@ async function provisionCrossCustomerPlan(context) {
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
- channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
+ channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs
index 788d65410..314b60a0d 100644
--- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs
+++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs
@@ -17,6 +17,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /\.\/orders\.mjs/)
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /context\.request\('POST', CONTAINERS/)
+ assert.match(source, /channelGroupCode:\s*channelGroup\(summary\.channelCode\)/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`')
const transportIndex = source.indexOf('ocean-booking-link')
diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs
index 199582d2f..d13797cd0 100644
--- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs
+++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs
@@ -9,6 +9,7 @@ const PRELOAD_STATUSES = ['EDITING', 'LOADING']
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
import { resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
+import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
@@ -127,7 +128,7 @@ async function createConfirmedContainer(context, source, label) {
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
- channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
+ channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs
index 14dc2b700..41ee5ace2 100644
--- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs
+++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs
@@ -106,6 +106,14 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
})
+test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
+ assert.match(source, /fixture\?\.incrementalInboundId \|\| fixture\?\.inboundId/)
+ assert.match(source, /const originalPackageCount = Number\(firstTree\?\.originalPackageCount\)/)
+ assert.match(source, /while \(activeRemainders\(tree\)\.length > 0\)/)
+ assert.match(source, /sourcePackageCount = Number\(source\?\.packageCount\)/)
+ assert.doesNotMatch(source, /snapshotMatches\(firstTree, 4, 1, 3, 1\)/)
+})
+
test('booking fixture never invents supplier names without formal IDs', () => {
assert.match(source, /oceanBookingDraft/)
assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/)
diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
index b498c44c8..0ceb8900f 100644
--- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs
+++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
@@ -486,7 +486,7 @@ async function loadOneFragment(context, state) {
export async function run(context) {
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId)
- const inboundId = id(fixture?.inboundId)
+ const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId)
const warehouseId = id(fixture?.warehouseId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
@@ -513,8 +513,20 @@ export async function run(context) {
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
+ const originalPackageCount = Number(firstTree?.originalPackageCount)
+ const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount)
+ const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount)
+ const initialActive = activeRemainders(firstTree)
+ const initialActiveSequence = Number(initialActive[0]?.splitSequence)
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
- treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
+ treeShape(firstTree, orderId)
+ && Number.isInteger(originalPackageCount) && originalPackageCount > 1
+ && Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0
+ && Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0
+ && originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount
+ && initialActive.length === 1 && Number.isInteger(initialActiveSequence)
+ && snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount,
+ initialRemainingPackageCount, initialActiveSequence)
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
@@ -536,7 +548,9 @@ export async function run(context) {
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
- treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
+ treeShape(merchantTree, orderId)
+ && snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount,
+ initialRemainingPackageCount, initialActiveSequence),
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
@@ -571,41 +585,54 @@ export async function run(context) {
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
}
let tree = firstTree
- for (const step of [
- { sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
- { sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
- { sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
- ]) {
+ let completedFragments = 0
+ while (activeRemainders(tree).length > 0) {
const active = activeRemainders(tree)
const source = active[0]
- context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
- active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
+ const sourceSuffix = Number(source?.splitSequence)
+ const sourcePackageCount = Number(source?.packageCount)
+ const beforeLoaded = Number(tree.cumulativeLoadedPackageCount)
+ const beforeRemaining = Number(tree.currentRemainderPackageCount)
+ context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null,
+ active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0
+ && source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}`
+ && Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining,
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
if (!source) return
const result = await loadOneFragment(context, {
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
customerName, channelCode, channelName, warehouseId, warehouseName,
- sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
+ sourcePackageCount, sequence: sourceSuffix,
})
if (!result?.tree) return
tree = result.tree
+ completedFragments += 1
+ const expectedLoaded = beforeLoaded + 1
+ const expectedRemaining = beforeRemaining - 1
+ const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null
Object.assign(context.createdResources.phase3OrderSplitting, {
activeRemainderCount: activeRemainders(tree).length,
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
})
- context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
+ context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse,
treeShape(tree, orderId)
- && snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
+ && snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix),
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
+ if (completedFragments > originalPackageCount) {
+ context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false,
+ `original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS)
+ return
+ }
}
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
+ const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) =>
+ `${tree.parentOrderNo}-${initialActiveSequence + index}`)
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
- JSON.stringify(remainderCodes) === JSON.stringify([
- `${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
- ]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
+ JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes)
+ && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
`codes=${remainderCodes.join(',')}`, OPTIONS)
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs
index 9e90032c5..0c82de1e1 100644
--- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs
+++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs
@@ -160,10 +160,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
- assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/)
+ assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/)
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
assert.match(source, /runUntilPickupReady\(this\)/)
const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix'))
+ assert.match(variant, /this\.phase4ProducerAttempts\.get\(label\)/)
+ assert.match(variant, /attempt === 1 \? '' : `-R\$\{attempt\}`/)
assert.ok(variant.indexOf('ensureInitialCalculated') < variant.indexOf('phase3-loading-phase4-'))
assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
assert.doesNotMatch(variant, /inboundFees\.run\(this\)/)
From 6cf144e53ddfb1f8bbfb0391219256211b455688 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 21:22:06 +0800
Subject: [PATCH 09/63] feat: expose dispatch BOL lines
---
.../dispatch/dto/DispatchResponse.java | 7 +++-
.../dispatch/mapper/DispatchBolMapper.java | 35 +++++++++++++++++++
.../dispatch/service/DispatchService.java | 16 +++++++--
.../openapi/overseas-business-v1.yaml | 15 ++++++++
.../dispatch/service/DispatchServiceTest.java | 14 +++++++-
5 files changed, 82 insertions(+), 5 deletions(-)
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
index 5e7d2a81d..7de25f0a8 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
@@ -12,11 +12,16 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
- List- items) {
+ List
- items, List bolLines) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
}
+
+ public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
+ String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
+ String poReferenceNo, int packageCount) {
+ }
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java
new file mode 100644
index 000000000..254dbdded
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java
@@ -0,0 +1,35 @@
+package com.baiyue.lastleg.dispatch.mapper;
+
+import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface DispatchBolMapper {
+ @Select("SELECT " +
+ "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
+ "MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
+ "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
+ "MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
+ "COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
+ "COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
+ "GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
+ "GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
+ "CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
+ "WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
+ "ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
+ "FROM ll_dispatch_item di " +
+ "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
+ "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
+ "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
+ "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
+ "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
+ "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
+ "WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " +
+ "GROUP BY di.id,l.id ORDER BY di.id,l.id")
+ List findByDispatchOrder(@Param("customerId") String customerId,
+ @Param("dispatchOrderId") Long dispatchOrderId);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
index 9a3e3eca2..dc64903dc 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
+import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
@@ -49,6 +50,7 @@ public class DispatchService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
+ @Resource private DispatchBolMapper bolLines;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@@ -429,7 +431,8 @@ public class DispatchService {
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
- return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
+ return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
+ bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
public DispatchResponse platformDetail(String dispatchNo) {
@@ -437,7 +440,8 @@ public class DispatchService {
if (dispatch == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
- return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
+ return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
+ bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
@@ -499,6 +503,11 @@ public class DispatchService {
}
private DispatchResponse response(DispatchOrder order, List itemList) {
+ return response(order, itemList, List.of());
+ }
+
+ private DispatchResponse response(DispatchOrder order, List itemList,
+ List bolLineList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -515,7 +524,8 @@ public class DispatchService {
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
- Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
+ Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(),
+ bolLineList == null ? List.of() : bolLineList);
}
private static String route(List selected) {
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index ac1d64cd3..0989a3664 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -2251,6 +2251,10 @@ components:
partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
+ bolLines:
+ type: array
+ description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
+ items: { $ref: '#/components/schemas/DispatchBolLine' }
DispatchItem:
type: object
properties:
@@ -2269,6 +2273,17 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
+ DispatchBolLine:
+ type: object
+ properties:
+ containerNo: { type: string, nullable: true }
+ palletNo: { type: string, nullable: true }
+ fbaWarehouseCode: { type: string, nullable: true }
+ destinationWarehouseName: { type: string, nullable: true }
+ warehouseNo: { type: string, nullable: true, description: 来源入仓码/仓库单号 }
+ fbaShipmentId: { type: string, nullable: true, description: 来源冻结货件编号;多值以逗号分隔 }
+ poReferenceNo: { type: string, nullable: true, description: 来源冻结FBA跟踪/参考号;多值以逗号分隔 }
+ packageCount: { type: integer, minimum: 0 }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, COMPLETED, REJECTED, CANCELLED] }
RelabelMerchantPageResponse:
allOf:
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java
index aa328d90d..9dc7d7706 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
+import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -49,6 +50,7 @@ import static org.mockito.Mockito.when;
class DispatchServiceTest {
@Mock DispatchOrderMapper orders;
@Mock DispatchItemMapper items;
+ @Mock DispatchBolMapper bolLines;
@Mock DispatchPalletLockMapper locks;
@Mock DispatchCandidateMapper candidates;
@Mock DispatchDraftMapper drafts;
@@ -62,6 +64,7 @@ class DispatchServiceTest {
service = new DispatchService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
+ ReflectionTestUtils.setField(service, "bolLines", bolLines);
ReflectionTestUtils.setField(service, "locks", locks);
ReflectionTestUtils.setField(service, "candidates", candidates);
ReflectionTestUtils.setField(service, "drafts", drafts);
@@ -70,6 +73,7 @@ class DispatchServiceTest {
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
+ lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of());
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
}
@@ -322,6 +326,7 @@ class DispatchServiceTest {
service.detail("200", "PC1");
verify(items).findByDispatchOrder("200", 1L);
+ verify(bolLines).findByDispatchOrder("200", 1L);
}
@Test
@@ -339,6 +344,7 @@ class DispatchServiceTest {
assertEquals(2, result.total());
verify(items).findByDispatchOrder("100", 1L);
verify(items).findByDispatchOrder("200", 2L);
+ verify(bolLines, never()).findByDispatchOrder(any(), any());
}
@Test
@@ -347,9 +353,15 @@ class DispatchServiceTest {
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch);
- service.platformDetail("PC1");
+ var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1",
+ "NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138);
+ when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected));
+
+ var result = service.platformDetail("PC1");
verify(items).findByDispatchOrder("200", 1L);
+ verify(bolLines).findByDispatchOrder("200", 1L);
+ assertEquals(List.of(expected), result.bolLines());
}
@Test
From e3dc5329aaafd855b475b7c269d91791b8d1950e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 21:26:52 +0800
Subject: [PATCH 10/63] fix(acceptance): reuse configured merchant credentials
---
.../admin-test-env-modules/inbound-fees-contract.test.mjs | 4 ++--
scripts/admin-test-env-modules/inbound-fees.mjs | 5 ++++-
.../phase4-carrier-nodes-contract.test.mjs | 2 +-
scripts/admin-test-env-modules/phase4-carrier-nodes.mjs | 2 +-
4 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
index 251f83da1..044718c60 100644
--- a/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
+++ b/scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
@@ -48,8 +48,8 @@ test('customer confirms chargeable quantity and manual unit price in one locked
const source = await readFile(moduleFile, 'utf8')
assert.match(source, /const orderId = id\(fixture\?\.orderId\)/)
assert.match(source, /context\.context\?\.merchantQualificationFixture/)
- assert.match(source, /merchantFixture\?\.username/)
- assert.match(source, /merchantFixture\?\.password/)
+ assert.match(source, /merchantFixture\?\.username \|\| MERCHANT_USERNAME/)
+ assert.match(source, /merchantFixture\?\.password \|\| MERCHANT_PASSWORD/)
assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/)
assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/)
assert.match(source, /Client 一次确认计费量、对客单价和费用/)
diff --git a/scripts/admin-test-env-modules/inbound-fees.mjs b/scripts/admin-test-env-modules/inbound-fees.mjs
index 3a4e8adfd..3ed2dfb03 100644
--- a/scripts/admin-test-env-modules/inbound-fees.mjs
+++ b/scripts/admin-test-env-modules/inbound-fees.mjs
@@ -1,5 +1,7 @@
const MODULE = 'inbound-fees'
const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true }
+const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
+const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
function id(value) { return value === null || value === undefined ? '' : String(value) }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
@@ -134,7 +136,8 @@ export async function run(context) {
const orderId = id(fixture?.orderId)
const merchantFixture = context.context?.merchantQualificationFixture
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
- loginId: merchantFixture?.username, password: merchantFixture?.password,
+ loginId: merchantFixture?.username || MERCHANT_USERNAME,
+ password: merchantFixture?.password || MERCHANT_PASSWORD,
}, '')
const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse,
`orderId=${orderId || 'missing'}`, OPTIONS)
diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs
index a4a33b8a2..efd86abc8 100644
--- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs
+++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs
@@ -185,7 +185,7 @@ function markerOwnedContext() {
if (body.actualAt != null
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
return conflict(`req-monotonic-${key}`,
- `人工实际节点必须严格晚于当前最新实际节点:${task.latestActualNode}`)
+ '实际船节点必须按业务顺序向后推进')
}
const providerEventId = `manual-${String(nextId)}`
nodes.push({
diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
index 21a1e2586..f9e53aa20 100644
--- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
+++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
@@ -34,7 +34,7 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
function monotonicRejected(response) {
return rejected(response)
- && String(response?.body?.message || '').includes('人工实际节点必须严格晚于当前最新实际节点')
+ && String(response?.body?.message || '').includes('实际船节点必须按业务顺序向后推进')
&& Boolean(response?.requestId) && Boolean(response?.traceId)
}
function localIso(value) { return value.toISOString().slice(0, 19) }
From 6fa2260572b535b7ca19b638ba062243c4af0800 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 21:29:40 +0800
Subject: [PATCH 11/63] fix(acceptance): derive split assertions from real
cargo
---
scripts/admin-test-env-modules/phase3-order-splitting.mjs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
index 0ceb8900f..a7a624b68 100644
--- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs
+++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs
@@ -239,9 +239,9 @@ async function loadOneFragment(context, state) {
const candidate = Array.isArray(candidates)
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
: null
- context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且可用箱数正确`, candidatesResponse,
+ context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且存在可用货物明细`, candidatesResponse,
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
- && Number(candidate?.availablePackageCount) === sourcePackageCount,
+ && Number(candidate?.availablePackageCount) >= 1,
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
@@ -535,7 +535,8 @@ export async function run(context) {
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
- && firstLoaded?.orderNo === firstTree.parentOrderNo && Number(firstLoaded?.packageCount) === 1,
+ && firstLoaded?.orderNo === firstTree.parentOrderNo
+ && Number(firstLoaded?.packageCount) === initialLoadedPackageCount,
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
const merchantLogin = await context.request('POST', '/api/auth/login', {
From e531ae94072a79482d8fb8c0c441fbe295d5d28e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 21:53:15 +0800
Subject: [PATCH 12/63] fix(acceptance): require FBA pallet label configuration
---
.../orders-pricing-fixture-contract.test.mjs | 12 +++++++++++-
scripts/admin-test-env-modules/orders.mjs | 10 ++++++++--
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs
index 1cdcc0f66..5198741cb 100644
--- a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs
+++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
-import { findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
+import { eligibleOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
@@ -18,6 +18,16 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA
assert.match(source, /warehouse\?\.status === 'ACTIVE'/)
})
+test('FBA order fixture excludes warehouses without the internal code required by pallet labels', () => {
+ const warehouses = [
+ { id: 'abe3', warehouseCode: 'ABE3', status: 'ACTIVE', internalCode: null },
+ { id: 'abe4', warehouseCode: 'ABE4', status: 'ACTIVE', internalCode: '126' },
+ { id: 'abe8', warehouseCode: 'ABE8', status: 'INACTIVE', internalCode: '39' },
+ ]
+ assert.deepEqual(eligibleOrderDestinations(warehouses, 'FBA').map((item) => item.warehouseCode), ['ABE4'])
+ assert.deepEqual(eligibleOrderDestinations(warehouses, 'OVERSEAS').map((item) => item.warehouseCode), ['ABE3', 'ABE4'])
+})
+
test('pricing selection skips an ACTIVE domestic warehouse with an empty channel catalog', async () => {
const channelCalls = []
const result = await findPricedOrderRouteAcrossOrigins({
diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs
index b64056891..56d45e27b 100644
--- a/scripts/admin-test-env-modules/orders.mjs
+++ b/scripts/admin-test-env-modules/orders.mjs
@@ -55,6 +55,12 @@ export function requestedOrderDestinationType(context) {
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
}
+export function eligibleOrderDestinations(values, destinationType) {
+ const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE')
+ return destinationType === 'FBA'
+ ? active.filter((warehouse) => String(warehouse?.internalCode || '').trim() !== '')
+ : active
+}
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
const summary = orderDetail?.summary
const task = success(taskResponse) ? taskResponse.body?.data : null
@@ -251,13 +257,13 @@ async function loadMasterData(context, merchantToken) {
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
}
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
- let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
+ let destinations = eligibleOrderDestinations(records(destinationPage), destinationType)
if (Number(destinationPage?.total) > destinations.length) {
const nextResponse = await request(context, 'GET',
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
undefined, merchantToken)
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
- destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')]
+ destinations = [...destinations, ...eligibleOrderDestinations(records(nextPage), destinationType)]
}
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
From 56635ad4e448a7b8f1c3ba1c2033a599d637ffed Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 10 Sep 2026 22:43:11 +0800
Subject: [PATCH 13/63] feat: complete finance and overseas receiving workflow
---
.../event/FirstLegLoadingPlanCompletedV2.java | 14 +-
deploy/docker-compose.yml | 1 -
deploy/sql/头程数据库统一增量-MySQL8.sql | 50 ++-
deploy/sql/海外区域统一增量-MySQL8.sql | 236 ++++++++++-
finance/AGENTS.md | 1 +
.../baiyue/finance/dto/FinancePhase1Dtos.java | 1 +
.../baiyue/finance/dto/FinanceWalletDtos.java | 4 +-
.../FinanceCustomerAccountService.java | 44 ++-
.../FinancePhase1OperationService.java | 38 +-
.../finance/service/FinanceWalletService.java | 124 +++---
finance/src/main/resources/db/finance.sql | 14 +-
.../FinanceCustomerAccountServiceTest.java | 16 +-
.../FinancePhase1OperationBehaviorTest.java | 2 +-
.../FinanceSqlCommentContractTest.java | 2 +-
.../finance/FinanceWalletContractTest.java | 8 +-
.../FinanceWalletBehaviorTest.java | 108 +++--
.../mapper/DropshipPalletizationMapper.java | 86 ++--
.../mapper/LoadingCompletedEventMapper.java | 15 +-
.../service/DropshipPalletizationService.java | 26 +-
.../LoadingCompletedOutboxService.java | 53 ++-
.../db/phase3-smart-loading-schema.sql | 13 +-
.../LoadingCompletedOutboxServiceTest.java | 15 +-
lastleg-service/AGENTS.md | 7 +
.../dropship/dto/DropshipRequests.java | 6 +-
.../dropship/entity/DropshipOrderProfile.java | 4 +
.../dropship/mapper/DropshipCartonMapper.java | 8 +
.../mapper/DropshipOrderProfileMapper.java | 8 +
.../service/DropshipOrderService.java | 15 +
.../inboundplan/entity/InboundPlanPallet.java | 3 +
.../ReceivingContainerController.java | 2 +-
.../pda/dto/ReceivingContainerRequests.java | 10 +-
.../pda/dto/ReceivingContainerResponses.java | 54 ++-
.../pda/entity/ReceivingFbaPalletCount.java | 21 +
.../mapper/ReceivingFbaPalletCountMapper.java | 19 +
.../pda/repository/PdaWorkflowRepository.java | 6 +
.../service/InboundPdaWorkflowService.java | 67 +++-
.../service/ReceivingContainerService.java | 373 +++++++++++++++---
...ainerInboundPlanConversionCoordinator.java | 23 +-
.../service/InboundPlanService.java | 4 +-
.../mapper/InventoryCommandMapper.java | 4 +-
.../service/InventoryCommandService.java | 2 +-
.../service/LoadingCompletedConsumer.java | 36 +-
.../src/main/resources/db/lastleg.sql | 20 +-
.../openapi/overseas-business-v1.yaml | 40 +-
.../DropshipOrderControllerContractTest.java | 2 +-
.../service/DropshipOrderServiceTest.java | 4 +-
.../pda/ReceivingContainerContractTest.java | 14 +-
.../InboundPdaWorkflowServiceTest.java | 19 +
...ReceivingContainerServiceBehaviorTest.java | 119 ++++--
.../command/InventoryCommandServiceTest.java | 2 +-
.../Phase5OverseasUpgradeSqlContractTest.java | 2 +-
.../service/LoadingCompletedConsumerTest.java | 8 +-
order/AGENTS.md | 1 +
.../order/client/FinanceWalletClient.java | 23 ++
.../order/service/OrderEligibilityGuard.java | 24 ++
.../service/OrderEligibilityGuardTest.java | 46 +++
.../initialization-baseline-contract.test.mjs | 2 +-
scripts/db/incremental.sql | 16 +-
.../mapper/WarehouseResourceLockMapper.java | 7 +
.../WarehouseResourceLockRepository.java | 6 +
.../service/WarehousePalletBatchService.java | 63 ++-
.../service/WarehouseResourceLockService.java | 6 +-
.../src/main/resources/db/warehouse.sql | 4 +-
.../WarehousePalletBatchContractTest.java | 4 +-
.../WarehousePalletBatchServiceTest.java | 20 +
.../WarehouseResourceLockServiceTest.java | 23 ++
66 files changed, 1601 insertions(+), 417 deletions(-)
rename finance/src/test/java/com/baiyue/finance/{ => service}/FinanceWalletBehaviorTest.java (59%)
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingFbaPalletCount.java
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingFbaPalletCountMapper.java
create mode 100644 order/src/main/java/com/baiyue/order/client/FinanceWalletClient.java
diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java
index 905c6050d..8c64ee40f 100644
--- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java
+++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java
@@ -141,8 +141,18 @@ public record FirstLegLoadingPlanCompletedV2(
}
}
- /** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
- public record PalletSnapshot(String palletNo, List skuPackages) {}
+ /** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
+ public record PalletSnapshot(String palletNo, List skuPackages,
+ Integer manifestVersion, Integer manifestPackageCount,
+ Integer manifestFragmentCount, String manifestHash) {
+ public PalletSnapshot {
+ skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
+ }
+
+ public PalletSnapshot(String palletNo, List skuPackages) {
+ this(palletNo, skuPackages, null, null, null, null);
+ }
+ }
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 9f0c87d39..4b703a3c0 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -123,7 +123,6 @@ services:
FINANCE_DB_URL: ${FINANCE_DB_URL}
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
- FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00}
OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
order:
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index d939bf45c..a61546f79 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -5169,11 +5169,14 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
- UPDATE fl_container_booking container_booking
- JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
- SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
- container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
- WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
+ IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
+ AND table_name='fl_ocean_booking') THEN
+ UPDATE fl_container_booking container_booking
+ JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
+ SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
+ container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
+ WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
+ END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
@@ -18473,12 +18476,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
- actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
+ actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
- available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
+ available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
- credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
+ credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
+ credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -18488,7 +18492,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
- CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
+ CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -18530,6 +18534,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
+ credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -18544,8 +18549,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
- CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
- CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
+ CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
+ CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
@@ -18581,13 +18586,20 @@ BEGIN
ADD COLUMN credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信' AFTER credit_limit,
ADD COLUMN credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信' AFTER credit_used;
END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_expires_on') THEN
+ ALTER TABLE fin_wallet_account ADD COLUMN credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效' AFTER credit_frozen;
+ UPDATE fin_wallet_account
+ SET available_balance=actual_balance-credit_used-frozen_balance;
+ UPDATE fin_wallet_account
+ SET actual_balance=actual_balance-credit_used,
+ credit_used=0,
+ credit_expires_on=NULL;
+ END IF;
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND CONSTRAINT_NAME='chk_fin_wallet_account_balance' AND CONSTRAINT_TYPE='CHECK') THEN
ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance;
END IF;
- UPDATE fin_wallet_account
- SET available_balance=actual_balance+credit_limit-credit_used-frozen_balance;
ALTER TABLE fin_wallet_account ADD CONSTRAINT chk_fin_wallet_account_balance
- CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance);
+ CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen);
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_limit_after') THEN
ALTER TABLE fin_wallet_ledger
@@ -18595,11 +18607,19 @@ BEGIN
ADD COLUMN credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信' AFTER credit_limit_after,
ADD COLUMN available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信' AFTER credit_used_after;
END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_expires_on_after') THEN
+ ALTER TABLE fin_wallet_ledger ADD COLUMN credit_expires_on_after DATE NULL COMMENT '变动后授信到期日' AFTER available_credit_after;
+ END IF;
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_balance' AND CONSTRAINT_TYPE='CHECK') THEN
ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_balance;
END IF;
ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_balance
- CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after);
+ CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0);
+ IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_amount' AND CONSTRAINT_TYPE='CHECK') THEN
+ ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_amount;
+ END IF;
+ ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_amount
+ CHECK (amount <> 0 OR change_type='CREDIT_LIMIT');
END$$
DELIMITER ;
CALL migrate_fin_wallet_credit();
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index f7f4c93c5..953b4c5e7 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -753,8 +753,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
+ version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本',
PRIMARY KEY (id),
- UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
+ KEY idx_ll_inbound_pallet_no (pallet_no),
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
KEY idx_ll_inbound_pallet_plan (plan_id),
KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id)
@@ -1089,7 +1090,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
- active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
+ active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -1112,6 +1113,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
+CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
+ id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
+ group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
+ actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
+ KEY idx_ll_receiving_fba_group(session_id,group_key)
+) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
+
+
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -1387,6 +1398,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
+ carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照',
+ source_business_type VARCHAR(32) NULL COMMENT '来源业务类型',
+ source_business_no VARCHAR(128) NULL COMMENT '来源业务单号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
@@ -1395,6 +1409,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1523,6 +1541,23 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
+SET @ddl = (SELECT IF(EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
+), 'SELECT 1',
+ 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
+PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt;
+SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
+ AND SUM(seq_in_index=1 AND column_name='customer_id')=1
+ AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
+ FROM information_schema.statistics WHERE table_schema=DATABASE()
+ AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
+SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
+ AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
+SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
+ 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
+ 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
+PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID',
@@ -1550,9 +1585,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
- operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
- delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式',
- estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号',
+ operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
+ delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
+ estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -1570,6 +1605,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='客户转仓单';
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1'));
+PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt;
+
-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status'));
PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt;
@@ -1599,6 +1637,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code)
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
+CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
+ id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
+ transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
+ carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
+ total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_carton_mark (customer_id,carton_mark),
+ UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index),
+ KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
+) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
+
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键',
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
@@ -1656,6 +1706,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
+ delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
+ print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
+ shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
+ shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
+ shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址',
+ fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态',
+ outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号',
+ customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键',
+ customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间',
remark VARCHAR(1000) NULL COMMENT '备注',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态',
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量',
@@ -1681,6 +1740,19 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id)
) ENGINE=InnoDB COMMENT='客户换标申请单';
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
CREATE TABLE IF NOT EXISTS ll_relabel_item (
id BIGINT NOT NULL COMMENT '主键ID',
application_id BIGINT NOT NULL COMMENT '申请单ID',
@@ -1692,7 +1764,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
total_package_count INT NOT NULL COMMENT '总箱数',
notified_package_count INT NOT NULL COMMENT '已通知箱数',
- shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号',
+ shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)',
tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码',
label_requirement VARCHAR(1000) NULL COMMENT '换标要求',
@@ -1703,6 +1775,17 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
+ destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)',
+ operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
+ sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
+ source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)',
+ source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)',
+ source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)',
+ source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
+ source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
+ source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
+ source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照',
+ source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (customer_id,application_id),
@@ -1710,6 +1793,9 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
+PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt;
+
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -1817,11 +1903,14 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
+ manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
+ manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
- UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
+ UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
+ KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
@@ -2355,6 +2444,111 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
+-- 客户退货预报、PDA 签收与仓储费起算批次
+CREATE TABLE IF NOT EXISTS ll_return_order (
+ -- need_relabel is added below for existing deployments
+ id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
+) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
+SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+CREATE TABLE IF NOT EXISTS ll_return_order_item (
+ id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
+) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
+CREATE TABLE IF NOT EXISTS ll_return_receipt (
+ id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
+) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
+
+-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
+SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku');
+PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku;
+EXECUTE ll_return_item_drop_tracking_order_sku_stmt;
+DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt;
+SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking');
+PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking;
+EXECUTE ll_return_item_drop_tracking_stmt;
+DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt;
+SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order');
+PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order;
+EXECUTE ll_return_item_drop_order_stmt;
+DEALLOCATE PREPARE ll_return_item_drop_order_stmt;
+SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no');
+PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no;
+EXECUTE ll_return_item_drop_tracking_no_stmt;
+DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt;
+SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku');
+PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku;
+EXECUTE ll_return_item_add_order_sku_stmt;
+DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt;
+SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku');
+PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx;
+EXECUTE ll_return_item_add_sku_idx_stmt;
+DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt;
+UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
+SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound');
+PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check;
+EXECUTE ll_return_receipt_drop_bound_check_stmt;
+DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt;
+SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))';
+PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
+EXECUTE ll_return_receipt_alter_stmt;
+DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
+CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
+ id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
+ PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
+) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
+CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
+ id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
+) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
+
+-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
+PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
+CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
+ id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
+) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
+-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
+PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
+ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
+PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt;
+ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
+PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER destination_warehouse_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
+ id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
+) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
+CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
+ id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0)
+) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
+SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh'));
+PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt;
+-- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。
+UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL;
+ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减';
+CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
+ id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
+) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
+
-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG
-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE
@@ -2469,7 +2663,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
- UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
+ KEY idx_wh_pallet_binding_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
@@ -2525,7 +2719,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
PRIMARY KEY (id),
UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no),
- UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
+ KEY idx_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
@@ -3093,7 +3287,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
- active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
+ active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
@@ -3142,7 +3336,7 @@ PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receivin
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=NULL,active_container_key=NULL
-WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
+WHERE status IN ('COMPLETED','COMPLETED_WITH_DIFFERENCE') AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
DELIMITER $$
CREATE PROCEDURE assert_receiving_global_locks_migratable()
BEGIN
@@ -3192,6 +3386,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
+CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
+ id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
+ group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
+ actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
+ KEY idx_ll_receiving_fba_group(session_id,group_key)
+) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
+
+
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -3700,9 +3904,9 @@ BEGIN
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
- 'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
+ 'll_delivery_order','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
- 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
+ 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
END IF;
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
@@ -3816,9 +4020,9 @@ BEGIN
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
- 'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
+ 'll_delivery_order','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
- 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
+ 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
@@ -4562,7 +4766,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
- UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
+ UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), KEY idx_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
diff --git a/finance/AGENTS.md b/finance/AGENTS.md
index a408b9497..87820bedc 100644
--- a/finance/AGENTS.md
+++ b/finance/AGENTS.md
@@ -42,6 +42,7 @@
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
+- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
index 476a75f6a..51aaf07f3 100644
--- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
+++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java
@@ -33,6 +33,7 @@ public final class FinancePhase1Dtos {
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal availableCredit,
+ LocalDate creditExpiresOn, boolean creditActive,
int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java
index 5a8d4fd92..9f48b1313 100644
--- a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java
+++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java
@@ -12,6 +12,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalance, BigDecimal availableBalance,
BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal creditFrozen, BigDecimal availableCredit,
+ LocalDate creditExpiresOn, boolean creditActive,
int version, LocalDateTime updatedAt) { }
public record WalletPage(List records, long total, int page, int size, int pages) {
@@ -26,6 +27,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
BigDecimal availableCreditAfter,
+ LocalDate creditExpiresOnAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
@@ -45,7 +47,7 @@ public final class FinanceWalletDtos {
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
- public record CreditLimitRequest(BigDecimal creditLimit, String remark,
+ public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark,
int expectedAccountVersion) { }
public record WalletFreezeRequest(BigDecimal amount, String businessType,
diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java
index 6d9ecdc3f..c27ab8532 100644
--- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -21,18 +22,12 @@ public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
- private final BigDecimal defaultCreditLimit;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
- @Value("${baiyue.security.internal-service-token:}") String internalToken,
- @Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
+ @Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
- if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
- throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
- }
- this.defaultCreditLimit = defaultCreditLimit.setScale(2);
}
public PageEnvelope list(int page, int pageSize, String keyword, String status, Long customerId) {
@@ -60,6 +55,7 @@ public class FinanceCustomerAccountService {
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.creditLimit(), value.creditUsed(), value.availableCredit(),
+ value.creditExpiresOn(), value.creditActive(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
@@ -70,7 +66,7 @@ public class FinanceCustomerAccountService {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map result = new HashMap<>();
- customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
+ customerIds.forEach(id -> result.put(id, new MutableMetrics()));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
@@ -83,7 +79,7 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
- jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
+ jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
.forEach(row -> walletMetric(result, row));
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
.forEach(row -> pendingMetric(result, row));
@@ -100,10 +96,22 @@ public class FinanceCustomerAccountService {
MutableMetrics target = result.get(number.longValue());
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
- target.availableBalance = decimal(row.get("available_balance"));
target.creditLimit = decimal(row.get("credit_limit"));
+ BigDecimal creditFrozen = decimal(row.get("credit_frozen"));
+ target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate()
+ : row.get("credit_expires_on") instanceof LocalDate value ? value : null;
+ target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null
+ && !target.creditExpiresOn.isBefore(LocalDate.now());
target.creditUsed = decimal(row.get("credit_used"));
- target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
+ if (!target.creditActive && target.creditUsed.signum() > 0) {
+ target.actualBalance = target.actualBalance.subtract(target.creditUsed);
+ target.creditUsed = BigDecimal.ZERO;
+ }
+ target.availableCredit = target.creditActive
+ ? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
+ target.availableBalance = target.creditActive
+ ? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit)
+ : target.actualBalance.subtract(target.frozenBalance);
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
@@ -158,13 +166,15 @@ public class FinanceCustomerAccountService {
private BigDecimal creditLimit;
private BigDecimal creditUsed = BigDecimal.ZERO;
private BigDecimal availableCredit;
+ private LocalDate creditExpiresOn;
+ private boolean creditActive;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
- private MutableMetrics(BigDecimal defaultCreditLimit) {
- this.creditLimit = defaultCreditLimit;
- this.availableCredit = defaultCreditLimit;
- this.availableBalance = defaultCreditLimit;
+ private MutableMetrics() {
+ this.creditLimit = BigDecimal.ZERO.setScale(2);
+ this.availableCredit = BigDecimal.ZERO.setScale(2);
+ this.availableBalance = BigDecimal.ZERO.setScale(2);
}
void set(String field, BigDecimal value) {
switch (field) {
@@ -179,6 +189,7 @@ public class FinanceCustomerAccountService {
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
+ creditExpiresOn, creditActive,
accountVersion, pendingRechargeCount, lastChangedAt); }
}
@@ -186,7 +197,8 @@ public class FinanceCustomerAccountService {
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
- BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
+ BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive,
+ int accountVersion, long pendingRechargeCount,
LocalDateTime lastChangedAt) {
}
}
diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
index 41f128c0f..1a64ca3a3 100644
--- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java
@@ -78,22 +78,46 @@ public class FinancePhase1OperationService {
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
+ "COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," +
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
+ "(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," +
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
- List rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
- money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
- money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
- money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
- money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
- rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
- rs.getString("account_status"), time(rs, "last_changed_at")), args);
+ List rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
+ private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException {
+ BigDecimal actualBalance = money(rs, "actual_balance");
+ BigDecimal frozenBalance = money(rs, "frozen_balance");
+ BigDecimal creditLimit = money(rs, "credit_limit");
+ BigDecimal creditFrozen = money(rs, "credit_frozen");
+ LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null
+ ? null : rs.getDate("credit_expires_on").toLocalDate();
+ boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
+ && !creditExpiresOn.isBefore(LocalDate.now());
+ BigDecimal creditUsed = money(rs, "credit_used");
+ if (!creditActive && creditUsed.signum() > 0) {
+ actualBalance = actualBalance.subtract(creditUsed);
+ creditUsed = BigDecimal.ZERO;
+ }
+ BigDecimal availableCredit = creditActive
+ ? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
+ BigDecimal availableBalance = creditActive
+ ? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit)
+ : actualBalance.subtract(frozenBalance);
+ return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")),
+ rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"),
+ money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"),
+ money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance,
+ availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive,
+ rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"),
+ time(rs, "last_changed_at"));
+ }
+
public PageEnvelope listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
String businessType, LocalDate startDate, LocalDate endDate) {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java
index 29faab5a1..0d1f81a09 100644
--- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java
+++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java
@@ -53,7 +53,6 @@ public class FinanceWalletService {
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
- private final BigDecimal defaultCreditLimit;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
@@ -61,7 +60,6 @@ public class FinanceWalletService {
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
- @Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -69,7 +67,6 @@ public class FinanceWalletService {
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
- this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
@@ -84,7 +81,7 @@ public class FinanceWalletService {
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List rows = jdbc.query(
- "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
+ "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> accountRow(rs), customerId);
return rows.isEmpty() ? zeroAccount() : rows.get(0);
}
@@ -202,7 +199,7 @@ public class FinanceWalletService {
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
- if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
+ if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
@@ -225,19 +222,26 @@ public class FinanceWalletService {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
+ LocalDate expiresOn = request.creditExpiresOn();
+ if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
+ throw bad("授信到期日必须晚于今天");
+ }
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) {
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
- if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
+ BigDecimal existingDebt = before.actualBalance().signum() < 0
+ ? before.actualBalance().negate() : before.creditUsed();
+ if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
}
BigDecimal delta = limit.subtract(before.creditLimit());
- if (delta.signum() == 0) throw bad("授信额度未发生变化");
- WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
- before.creditUsed(), before.creditFrozen(), before.version());
+ if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
+ BigDecimal carriedDebt = existingDebt;
+ WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
+ carriedDebt, before.creditFrozen(), expiresOn, before.version());
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
@@ -273,11 +277,12 @@ public class FinanceWalletService {
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
}
BigDecimal actualAvailable = before.actualBalance()
- .subtract(before.frozenBalance().subtract(before.creditFrozen()));
+ .subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO);
BigDecimal actualFrozen = amount.min(actualAvailable);
BigDecimal creditFrozen = amount.subtract(actualFrozen);
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
- before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
+ before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen),
+ before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
clean(request.remark(), 500));
@@ -315,12 +320,18 @@ public class FinanceWalletService {
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
- BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
- BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
+ BigDecimal actual = before.actualBalance();
+ BigDecimal creditUsed = before.creditUsed();
+ if (settle && before.creditActive()) {
+ actual = actual.subtract(hold.actualFrozenAmount());
+ creditUsed = creditUsed.add(hold.creditFrozenAmount());
+ } else if (settle) {
+ actual = actual.subtract(hold.amount());
+ }
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
- creditFrozen, before.version());
+ creditFrozen, before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
String action = settle ? "SETTLED" : "RELEASED";
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
@@ -346,17 +357,17 @@ public class FinanceWalletService {
}
private WalletAccount accountForUpdate(long customerId) {
- jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
- customerId, defaultCreditLimit, defaultCreditLimit);
+ jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
+ customerId);
return jdbc.queryForObject(
- "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
+ "SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> accountRow(rs), customerId);
}
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
- int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
+ int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
- account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
+ account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
@@ -369,17 +380,17 @@ public class FinanceWalletService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
- "INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ "INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
- statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
- statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
- statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
- statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
- statement.setString(18, clean(remark, 500)); return statement;
+ statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn());
+ statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
+ statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
+ statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
+ statement.setString(19, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
@@ -392,48 +403,70 @@ public class FinanceWalletService {
}
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
- BigDecimal creditRepayment = amount.min(before.creditUsed());
- return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
- before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
+ BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
+ return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
+ before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
+ before.creditExpiresOn(), before.version());
}
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
- BigDecimal actualAvailable = before.actualBalance()
- .subtract(before.frozenBalance().subtract(before.creditFrozen()));
+ if (!before.creditActive()) {
+ return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
+ before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
+ }
+ BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
+ BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
BigDecimal actualDeduction = amount.min(actualAvailable);
- BigDecimal creditDeduction = amount.subtract(actualDeduction);
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
- before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
+ before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
+ before.creditFrozen(), before.creditExpiresOn(), before.version());
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
- BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
- BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
+ BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
+ return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
+ }
+
+ WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
+ BigDecimal creditUsed, BigDecimal creditFrozen,
+ LocalDate creditExpiresOn, int version) {
+ boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
+ && !creditExpiresOn.isBefore(LocalDate.now());
+ if (!creditActive && creditUsed.signum() > 0) {
+ actual = actual.subtract(creditUsed);
+ creditUsed = BigDecimal.ZERO.setScale(2);
+ }
+ BigDecimal availableCredit = creditActive
+ ? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
- BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
- if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
- || creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
- || availableCredit.signum() < 0 || available.signum() < 0) {
+ BigDecimal available = creditActive
+ ? actual.subtract(actualFrozen).add(availableCredit)
+ : actual.subtract(frozen);
+ if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
+ || creditUsed.signum() < 0 || actualFrozen.signum() < 0
+ || (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
throw error("FIN-WALLET-409", "账户余额状态无效");
}
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
- creditFrozen, availableCredit, version, null);
+ creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
}
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
- BigDecimal creditUsed = rs.getBigDecimal("credit_used");
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
- return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
- rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
- creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
+ LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
+ WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
+ creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
+ return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
+ calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
+ calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
timestamp(rs.getTimestamp("updated_at")));
}
private WalletAccount zeroAccount() {
BigDecimal zero = BigDecimal.ZERO.setScale(2);
- return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
- zero, defaultCreditLimit, 0, null);
+ return new WalletAccount("CNY", zero, zero, zero, zero, zero,
+ zero, zero, null, false, 0, null);
}
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
@@ -546,6 +579,7 @@ public class FinanceWalletService {
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
+ rs.getObject("credit_expires_on_after", LocalDate.class),
rs.getString("source_type"), rs.getString("source_no"),
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql
index 4ec095e21..d704277da 100644
--- a/finance/src/main/resources/db/finance.sql
+++ b/finance/src/main/resources/db/finance.sql
@@ -443,12 +443,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
- actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
+ actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
- available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
+ available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
- credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
+ credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
+ credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -458,7 +459,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
- CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
+ CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
+ credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -514,8 +516,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
- CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
- CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
+ CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
+ CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java
index 00981425f..89dbc7f59 100644
--- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java
+++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
class FinanceCustomerAccountServiceTest {
@Test
- void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
+ void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
@@ -32,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
1, 20, 1, 1)));
- var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
+ var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
@@ -41,9 +41,11 @@ class FinanceCustomerAccountServiceTest {
assertThat(row.contactPhone()).isEqualTo("13800138000");
assertThat(row.outstandingReceivable()).isZero();
assertThat(row.pendingBilling()).isZero();
- assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
- assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
- assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
+ assertThat(row.creditLimit()).isZero();
+ assertThat(row.availableCredit()).isZero();
+ assertThat(row.availableBalance()).isZero();
+ assertThat(row.creditExpiresOn()).isNull();
+ assertThat(row.creditActive()).isFalse();
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@@ -56,7 +58,7 @@ class FinanceCustomerAccountServiceTest {
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
1, 20, 1, 1)));
- new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
+ new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
@@ -72,7 +74,7 @@ class FinanceCustomerAccountServiceTest {
@Test
void rejectsUnsupportedFinanceAccountStatus() {
- var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
+ var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java
index b794badf7..7b826cd3b 100644
--- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java
+++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java
@@ -375,7 +375,7 @@ class FinancePhase1OperationBehaviorTest {
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
- BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
+ BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
index d1182edb7..964d12bdd 100644
--- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
+++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
- assertThat(columnCount).isEqualTo(351);
+ assertThat(columnCount).isEqualTo(353);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java
index e2d340709..db0ceee41 100644
--- a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java
+++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java
@@ -14,12 +14,14 @@ class FinanceWalletContractTest {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
- .contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
- .contains("credit_limit >= credit_used+credit_frozen")
+ .contains("credit_expires_on DATE NULL")
+ .contains("actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0")
+ .contains("frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0")
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
.contains("status IN ('PENDING','APPROVED','REJECTED')")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
- .contains("amount <> 0")
+ .contains("amount <> 0 OR change_type='CREDIT_LIMIT'")
+ .contains("credit_expires_on_after DATE NULL")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java
similarity index 59%
rename from finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java
rename to finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java
index 456f67ed5..4a37ce725 100644
--- a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java
+++ b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java
@@ -1,7 +1,6 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
-import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -9,8 +8,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
-import java.sql.ResultSet;
-import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
@@ -48,7 +45,7 @@ class FinanceWalletBehaviorTest {
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
- BigDecimal.ZERO, "cn", "development").channels();
+ "cn", "development").channels();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
@@ -57,44 +54,29 @@ class FinanceWalletBehaviorTest {
}
@Test
- void manualDeductionCannotExceedAvailableBalance() throws Exception {
- JdbcTemplate jdbc = mock(JdbcTemplate.class);
- when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
- when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
- when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
- when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
- @SuppressWarnings("unchecked") RowMapper
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
- @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct;
+ @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct; private String packingGroupNo;
}
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
index 6772a5ca1..94ed30daf 100644
--- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
+++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
@@ -84,9 +84,8 @@ public class MerchantInboundOrderListService {
List missing, Map> cargoes) {
try {
List commands = missing.stream().map(order -> {
- int packages = cargoes.getOrDefault(order.getId(), List.of()).stream()
- .map(OrderCargoEntity::getPackageCount).filter(Objects::nonNull)
- .mapToInt(Integer::intValue).sum();
+ int packages = OrderCargoPackageCounts.physical(
+ cargoes.getOrDefault(order.getId(), List.of()), order.getFulfillmentType());
return OrderApplicationService.inboundCommand(order, packages);
}).toList();
var response = firstleg.prepareBatch(internalToken, commands);
@@ -105,8 +104,7 @@ public class MerchantInboundOrderListService {
OrderPartyEntity consignee,
OrderStatusHistoryEntity latestHistory,
FirstlegInboundClient.InboundReference inbound) {
- int packageCount = cargoes.stream().map(OrderCargoEntity::getPackageCount)
- .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
+ int packageCount = OrderCargoPackageCounts.physical(cargoes, order.getFulfillmentType());
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
BigDecimal grossWeight = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
index de11b6b0b..d9c3d0825 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
@@ -31,9 +31,13 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.time.*;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
@@ -81,6 +85,7 @@ public class OrderApplicationService {
OrderRequests.SaveRequest request) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
+ validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
@@ -122,6 +127,7 @@ public class OrderApplicationService {
validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (allowSubmitted) validateCompleteRequest(request);
+ else validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
OrderStatus before = order.getStatus();
@@ -296,7 +302,8 @@ public class OrderApplicationService {
}
var cargoes = repository.findCargo(tenantId, order.getId());
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
- for (var cargo : cargoes) {
+ for (int cargoIndex = 0; cargoIndex < cargoes.size(); cargoIndex++) {
+ var cargo = cargoes.get(cargoIndex);
if (!StringUtils.hasText(cargo.getCargoName()) || !StringUtils.hasText(cargo.getPackageType())
|| cargo.getPackageCount() == null || cargo.getPackageCount() < 1
|| cargo.getGrossWeightKg() == null || cargo.getGrossWeightKg().signum() <= 0
@@ -304,6 +311,7 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
+ validatePersistedCargoQuantity(cargo, cargoIndex, true);
if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
@@ -343,6 +351,7 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
}
validateWoodInspection(request);
+ validateRequestCargoQuantities(request.cargoes(), true);
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
@@ -352,6 +361,7 @@ public class OrderApplicationService {
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
}
+ validateMixedCartons(request);
if ("FBA".equals(request.destinationType())) {
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|| !StringUtils.hasText(request.referenceId())) {
@@ -368,6 +378,36 @@ public class OrderApplicationService {
}
}
+ private void validateRequestCargoQuantities(List cargoes, boolean required) {
+ for (int index = 0; index < cargoes.size(); index++) {
+ OrderRequests.Cargo cargo = cargoes.get(index);
+ Integer unitsPerPackage = cargo.unitsPerPackage();
+ Integer totalQuantity = cargo.totalQuantity();
+ if (unitsPerPackage == null && totalQuantity == null && !required) continue;
+ validateCargoQuantity(unitsPerPackage, cargo.packageCount(), totalQuantity, index, required);
+ }
+ }
+
+ private void validatePersistedCargoQuantity(OrderCargoEntity cargo, int index, boolean required) {
+ validateCargoQuantity(cargo.getUnitsPerPackage(), cargo.getPackageCount(), cargo.getTotalQuantity(), index, required);
+ }
+
+ private void validateCargoQuantity(Integer unitsPerPackage, Integer packageCount, Integer totalQuantity,
+ int index, boolean required) {
+ if (unitsPerPackage == null || packageCount == null || totalQuantity == null) {
+ if (required || unitsPerPackage != null || totalQuantity != null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "第 " + (index + 1) + " 条货物必须填写每箱数量、箱数和总数量");
+ }
+ return;
+ }
+ long expected = (long) unitsPerPackage * packageCount;
+ if (totalQuantity.longValue() != expected) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "第 " + (index + 1) + " 条货物总数量应等于每箱数量 × 箱数(" + expected + ")");
+ }
+ }
+
private boolean hasParty(List parties, String partyType) {
return parties != null && parties.stream()
.anyMatch(party -> party != null && partyType.equals(party.getPartyType()));
@@ -601,6 +641,7 @@ public class OrderApplicationService {
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
item.setWoodenProduct(cargo.woodenProduct());
+ item.setPackingGroupNo(normalize(cargo.packingGroupNo()));
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (addresses.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", addresses.sender());
@@ -706,8 +747,7 @@ public class OrderApplicationService {
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
- int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount)
- .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
+ int packageCount = physicalPackageCountFromRequests(request.cargoes(), request.fulfillmentType());
prepareSubmittedInbound(order, packageCount);
}
@@ -716,11 +756,7 @@ public class OrderApplicationService {
* 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。
*/
private void prepareSubmittedInbound(OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
+ long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null);
}
@@ -745,16 +781,53 @@ public class OrderApplicationService {
}
}
private int totalPackageCount(OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
+ long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
if (total < 1 || total > 1_000_000) {
throw new BizException("订单货物总箱数必须为1至1000000箱");
}
return (int) total;
}
+ private void validateMixedCartons(OrderRequests.SaveRequest request) {
+ if (!"ONE_CLICK_DROPSHIP".equals(request.fulfillmentType())) return;
+ Map firstByGroup = new LinkedHashMap<>();
+ for (OrderRequests.Cargo cargo : request.cargoes()) {
+ String group = normalize(cargo.packingGroupNo());
+ if (group == null) continue;
+ OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo);
+ if (first != null && (first.packageCount() != cargo.packageCount()
+ || !Objects.equals(first.packageType(), cargo.packageType())
+ || !sameDecimal(first.lengthCm(), cargo.lengthCm())
+ || !sameDecimal(first.widthCm(), cargo.widthCm())
+ || !sameDecimal(first.heightCm(), cargo.heightCm()))) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "同一混箱组合的箱数、包装类型和箱规必须一致:" + group);
+ }
+ }
+ }
+ private static int physicalPackageCountFromRequests(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ String group = normalize(cargo.packingGroupNo());
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return cargo.packageCount();
+ return countedGroups.add(group) ? cargo.packageCount() : 0;
+ }).sum();
+ }
+ private static int physicalPackageCountFromEntities(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ String group = normalize(cargo.getPackingGroupNo());
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) {
+ return cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
+ }
+ return countedGroups.add(group) && cargo.getPackageCount() != null ? cargo.getPackageCount() : 0;
+ }).sum();
+ }
+ private static boolean sameDecimal(java.math.BigDecimal left, java.math.BigDecimal right) {
+ return left == null ? right == null : right != null && left.compareTo(right) == 0;
+ }
+ private static String normalize(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
private String nextNo(long orderId) {
return "FL" + orderId;
}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
new file mode 100644
index 000000000..555357944
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
@@ -0,0 +1,24 @@
+package com.baiyue.order.service;
+
+import com.baiyue.order.entity.OrderCargoEntity;
+import org.springframework.util.StringUtils;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/** Calculates physical cartons without double-counting SKU lines in a mixed-carton group. */
+public final class OrderCargoPackageCounts {
+ private OrderCargoPackageCounts() {}
+
+ public static int physical(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ int count = cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
+ String group = StringUtils.hasText(cargo.getPackingGroupNo()) ? cargo.getPackingGroupNo().trim() : null;
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return count;
+ return countedGroups.add(group) ? count : 0;
+ }).sum();
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
index 67caa0a69..37cd57cbf 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
@@ -141,12 +141,9 @@ public class OrderInboundOutboxPublisher {
}
private int totalPackageCount(com.baiyue.order.entity.OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
- return total > 0 && total <= 1_000_000 ? (int) total : 0;
+ int total = OrderCargoPackageCounts.physical(
+ repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
+ return total > 0 && total <= 1_000_000 ? total : 0;
}
private void failPermanently(OutboxEventEntity event, String reason) {
diff --git a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
index a789cf392..aedad4660 100644
--- a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
+++ b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
@@ -48,9 +48,8 @@ public class PrintableInboundSnapshotService {
}
}
}
- int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
- .map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
- .mapToInt(Integer::intValue).sum();
+ int packageCount = OrderCargoPackageCounts.physical(
+ orders.findCargoInheritedByOrder(tenantId, orderId), order.getFulfillmentType());
return OrderApplicationService.inboundCommand(order, packageCount);
}
diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
index 599cdb311..779068add 100644
--- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
+++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
@@ -130,7 +130,7 @@ public class UnifiedOrderQueryService {
.findFirst()
.orElse(null));
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
- childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName,
+ childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes(), order.getFulfillmentType()), creatorName,
coLoadGroups(List.of(orderId), includeCoLoadGroups).get(orderId));
List timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
@@ -492,7 +492,7 @@ public class UnifiedOrderQueryService {
private record CargoSummary(List skuNos, List shipmentIds, List referenceIds,
Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg,
BigDecimal netWeightKg, BigDecimal volumeCbm) {
- private static CargoSummary from(List cargoes) {
+ private static CargoSummary from(List cargoes, String fulfillmentType) {
List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
List shipmentIds = cargoes.stream().map(OrderCargoEntity::getShipmentId)
@@ -501,10 +501,10 @@ public class UnifiedOrderQueryService {
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
- List packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount)
- .filter(Objects::nonNull).toList();
- Integer totalPackageCount = packageCounts.isEmpty() ? null
- : Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum());
+ boolean hasPackageCount = cargoes.stream().anyMatch(cargo -> cargo.getPackageCount() != null);
+ Integer totalPackageCount = hasPackageCount
+ ? OrderCargoPackageCounts.physical(cargoes, fulfillmentType)
+ : null;
BigDecimal grossWeightKg = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
BigDecimal netWeightKg = sum(cargoes, OrderCargoEntity::getNetWeightKg);
BigDecimal volumeCbm = sum(cargoes, OrderCargoEntity::getVolumeCbm);
diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql
index d4574aaeb..7c3d24e80 100644
--- a/order/src/main/resources/db/order.sql
+++ b/order/src/main/resources/db/order.sql
@@ -304,6 +304,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检',
+ packing_group_no VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql
index 00dd9e56b..d6957530c 100644
--- a/order/src/main/resources/db/phase1-order-extension.sql
+++ b/order/src/main/resources/db/phase1-order-extension.sql
@@ -60,6 +60,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'File服务中的产品图片逻辑文件ID'");
+CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 787828a1c..1096cd56b 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -477,6 +477,7 @@ paths:
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
+ description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
@@ -581,6 +582,7 @@ paths:
/api/platform/v1/orders/first-leg/{id}/submit:
post:
summary: 平台代客提交指定订单
+ description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
@@ -1258,11 +1260,12 @@ components:
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
- unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
+ unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量;提交时必填}
packageCount: {type: integer, minimum: 1, description: 箱数}
+ packingGroupNo: {type: string, maxLength: 32, description: 一件代发混箱组合号;相同组合号的货物行共享同一批物理箱,组内箱数、包装类型和箱规必须一致}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
- totalQuantity: {type: integer, minimum: 1, description: 总数量}
+ totalQuantity: {type: integer, minimum: 1, description: 总数量;提交时必填且必须等于每箱数量 × 箱数}
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
@@ -1282,7 +1285,7 @@ components:
woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
- productImageFileId 必须属于当前登录客户。
+ productImageFileId 必须属于当前登录客户。一件代发中,相同 packingGroupNo 的多条 SKU 行属于同一批物理箱,物理箱数只计算一次。
FirstLegOrderSummary:
type: object
properties:
diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
index e685ef60d..0b7954d81 100644
--- a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
@@ -52,6 +52,8 @@ class MerchantBatchDraftOpenApiContractTest {
assertThat(openapi)
.contains("/api/merchant/v1/orders/first-leg:")
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
+ .contains("总数量必须等于每箱数量 × 箱数")
+ .contains("总数量;提交时必填且必须等于每箱数量 × 箱数")
.contains("$ref: '#/components/schemas/FirstLegOrderDraft'");
}
}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
index 98f67b1e7..bf6ea159b 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
@@ -103,6 +103,21 @@ class OrderApplicationServiceStateTest {
&& "自动化联调测试,请勿实际出货".equals(saved.getCargoRemark())));
}
+ @Test
+ void creatingDraftRejectsMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderRequests.SaveRequest request = requestWithCargoQuantities(5, 24, 119);
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .createDraft(10001L, 2L, request, "mismatched-quantity-on-create"))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).insertOrder(any());
+ }
+
@Test
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
@@ -194,6 +209,22 @@ class OrderApplicationServiceStateTest {
ordered.verify(repository).saveOrder(10001L, order);
}
+ @Test
+ void updatingDraftRejectsMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderEntity order = persistedOrder(OrderStatus.DRAFT);
+ when(repository.findOwned(10001L, 1L)).thenReturn(order);
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .updateDraft(10001L, 2L, 1L, requestWithCargoQuantities(5, 24, 119)))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).saveOrder(anyLong(), any());
+ }
+
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
@@ -584,6 +615,8 @@ class OrderApplicationServiceStateTest {
cargo.setPackageCount(1);
cargo.setGrossWeightKg(BigDecimal.ONE);
cargo.setVolumeCbm(BigDecimal.ONE);
+ cargo.setUnitsPerPackage(1);
+ cargo.setTotalQuantity(1);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
@@ -617,6 +650,27 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).saveOrder(anyLong(), any());
}
+ @Test
+ void submittingDraftRejectsPersistedMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
+ OrderCargoEntity cargo = completeCargo();
+ cargo.setPackageCount(5);
+ cargo.setUnitsPerPackage(24);
+ cargo.setTotalQuantity(119);
+ when(repository.findOwned(20002L, 1L)).thenReturn(order);
+ when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).saveOrder(anyLong(), any());
+ }
+
@Test
void updatingAnotherCustomersOrderStopsBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
@@ -672,7 +726,7 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null,
+ null, 1, 1, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
@@ -699,7 +753,7 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null,
+ null, 1, 1, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
@@ -722,6 +776,25 @@ class OrderApplicationServiceStateTest {
amazonWarehouseCode, shipmentId, referenceId);
}
+ private OrderRequests.SaveRequest requestWithCargoQuantities(int packageCount, Integer unitsPerPackage,
+ Integer totalQuantity) {
+ OrderRequests.SaveRequest source = request();
+ var cargo = new OrderRequests.Cargo(
+ "货物", null, "CARTON", packageCount, BigDecimal.ONE, BigDecimal.ONE,
+ null, null, null, null, null, false, null, null, null, null,
+ null, unitsPerPackage, totalQuantity, null, null, null, null, null, null,
+ null, null, null, null, null, null);
+ return new OrderRequests.SaveRequest(
+ source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
+ source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
+ source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), List.of(cargo),
+ source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
+ source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
+ source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
+ source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
+ source.referenceId());
+ }
+
private OrderMasterDataGuard.Snapshots snapshots() {
return snapshots(null);
}
@@ -758,6 +831,8 @@ class OrderApplicationServiceStateTest {
cargo.setPackageCount(1);
cargo.setGrossWeightKg(BigDecimal.ONE);
cargo.setVolumeCbm(BigDecimal.ONE);
+ cargo.setUnitsPerPackage(1);
+ cargo.setTotalQuantity(1);
return cargo;
}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
new file mode 100644
index 000000000..e05da0868
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
@@ -0,0 +1,29 @@
+package com.baiyue.order.service;
+
+import com.baiyue.order.entity.OrderCargoEntity;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OrderCargoPackageCountsTest {
+ @Test
+ void sameMixedCartonGroupCountsPhysicalCartonsOnce() {
+ OrderCargoEntity skuA = cargo(10, "A");
+ OrderCargoEntity skuB = cargo(10, "A");
+ OrderCargoEntity separate = cargo(4, null);
+
+ assertThat(OrderCargoPackageCounts.physical(
+ List.of(skuA, skuB, separate), "ONE_CLICK_DROPSHIP")).isEqualTo(14);
+ assertThat(OrderCargoPackageCounts.physical(
+ List.of(skuA, skuB, separate), "STANDARD")).isEqualTo(24);
+ }
+
+ private OrderCargoEntity cargo(int count, String group) {
+ OrderCargoEntity value = new OrderCargoEntity();
+ value.setPackageCount(count);
+ value.setPackingGroupNo(group);
+ return value;
+ }
+}
diff --git a/print/AGENTS.md b/print/AGENTS.md
index 2542e3f2e..7f87e386e 100644
--- a/print/AGENTS.md
+++ b/print/AGENTS.md
@@ -4,7 +4,7 @@
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
-- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
+- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V1`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、最多两行入仓码和最多 34 行完整 SKU 归托信息,不展示作业状态。新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
## 快速索引
@@ -13,10 +13,10 @@
| 调用方接入指南 | `README.md` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
-| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
+| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
-| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
-| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
+| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/DropshipPalletA4PdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
+| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/DropshipPalletA4Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/DispatchBolPdfService.java`、`service/PdfGenerationLimiter.java` |
| PDF 参数配置 | `config/PrintPdfProperties.java` |
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
@@ -32,4 +32,4 @@
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
-- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码反向解码和字体嵌入。
+- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码或一维码反向解码和字体嵌入。
diff --git a/print/README.md b/print/README.md
index 990fbd657..4f45ee9af 100644
--- a/print/README.md
+++ b/print/README.md
@@ -8,6 +8,7 @@
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
+- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V1`,每托一页 A4,包含托盘 Code 128 一维码、最多两行入仓码、总箱数和最多 34 行 SKU/商品名称/箱数/实点数量,不展示作业状态。
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
- 本地直连:`http://127.0.0.1:9207`。
@@ -263,7 +264,7 @@ spring:
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
-3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
+3. 检查入仓标签页面为严格 `100mm x 100mm`,一件代发托盘信息单及其他 A4 单据为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
index 11f9b2fa2..d185bd58f 100644
--- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
+++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
@@ -5,9 +5,11 @@ import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.DispatchBolPdfDtos;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.DispatchBolPdfService;
+import com.baiyue.print.service.DropshipPalletA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -33,6 +35,7 @@ public class InternalPdfController {
@Resource private InboundMarkPdfService pdfService;
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private DispatchBolPdfService dispatchBolPdfService;
+ @Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -101,4 +104,17 @@ public class InternalPdfController {
.header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
}
+
+ @PostMapping(value = "/dropship-pallet-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity generateDropshipPalletA4Documents(
+ @RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
+ @Valid @RequestBody DropshipPalletA4PdfDtos.Request request) {
+ tokenGuard.verify(internalToken);
+ DropshipPalletA4PdfService.GeneratedPdf pdf = dropshipPalletA4PdfService.generate(request);
+ byte[] content = pdf.content();
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
+ .header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
+ .contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
+ }
}
diff --git a/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java
new file mode 100644
index 000000000..627e8ed85
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java
@@ -0,0 +1,23 @@
+package com.baiyue.print.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 一件代发托盘 A4 信息单的内部渲染契约。 */
+public final class DropshipPalletA4PdfDtos {
+ public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1";
+
+ private DropshipPalletA4PdfDtos() {}
+
+ public record Request(String templateCode, PageSpec pageSpec, String downloadFileName,
+ List documents) {}
+
+ public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
+
+ public record Document(String palletNo, String inboundCodes, int cartonCount,
+ int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
+ String barcodeContent, List skuRows) {}
+
+ public record SkuRow(String inboundCode, String skuCode, String cargoName,
+ int cartonCount, int skuQuantity) {}
+}
diff --git a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java
new file mode 100644
index 000000000..5cd8c08f4
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java
@@ -0,0 +1,167 @@
+package com.baiyue.print.renderer;
+
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.google.zxing.common.BitMatrix;
+import jakarta.annotation.Resource;
+import java.awt.Color;
+import java.io.IOException;
+import java.util.List;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.springframework.stereotype.Component;
+
+/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */
+@Component
+public class DropshipPalletA4Renderer {
+ private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202};
+ private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"};
+ private static final double TABLE_TOP = 105;
+ private static final double HEADER_HEIGHT = 8;
+ private static final double REGULAR_ROW_HEIGHT = 7;
+ private static final double DENSE_ROW_HEIGHT = 5.2;
+ private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158);
+ private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22);
+ @Resource private PdfBarcodeRenderer barcodeRenderer;
+
+ public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
+ BitMatrix barcodeMatrix) throws IOException {
+ PDPage page = new PDPage(PDRectangle.A4);
+ pdf.addPage(page);
+ PdfUnits units = new PdfUnits(297);
+ try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
+ stream.setNonStrokingColor(Color.BLACK);
+ stream.setLineWidth(PdfUnits.mmToPt(.3));
+ centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13);
+ centered(stream, units, font, data.palletNo(), 22, 105, 25);
+ barcode(stream, units, barcodeMatrix);
+ fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94);
+ inboundCodes(stream, units, font, data.inboundCodes());
+ line(stream, units, 8, 78, 202, 78);
+ metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5);
+ metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105);
+ metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5);
+ metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202);
+ line(stream, units, 56.5, 78, 56.5, 94);
+ line(stream, units, 105, 78, 105, 94);
+ line(stream, units, 153.5, 78, 153.5, 94);
+ line(stream, units, 8, 99, 202, 99);
+ text(stream, units, font, "SKU 明细", 11, 8, 104);
+ table(stream, units, font, data.skuRows());
+ }
+ }
+
+ private void barcode(PDPageContentStream stream, PdfUnits units, BitMatrix matrix) throws IOException {
+ float left = PdfUnits.mmToPt(26);
+ float bottom = units.topDownMmToPdfY(54);
+ barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT);
+ }
+
+ private void table(PDPageContentStream stream, PdfUnits units, PDFont font,
+ List rows) throws IOException {
+ int visibleRows = Math.max(1, rows.size());
+ double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT;
+ float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f;
+ double headerBottom = TABLE_TOP + HEADER_HEIGHT;
+ double bottom = headerBottom + visibleRows * rowHeight;
+ fill(stream, units, new Color(235, 239, 245), COLS[0], TABLE_TOP, COLS[COLS.length - 1], headerBottom);
+ for (float x : COLS) line(stream, units, x, TABLE_TOP, x, bottom);
+ for (int boundary = 0; boundary <= visibleRows; boundary++) {
+ line(stream, units, COLS[0], headerBottom + boundary * rowHeight,
+ COLS[COLS.length - 1], headerBottom + boundary * rowHeight);
+ }
+ line(stream, units, COLS[0], TABLE_TOP, COLS[COLS.length - 1], TABLE_TOP);
+ for (int column = 0; column < HEADERS.length; column++) {
+ centeredCell(stream, units, font, HEADERS[column], 7.2f,
+ COLS[column] + .5, COLS[column + 1] - .5, TABLE_TOP + 5.5);
+ }
+ if (rows.isEmpty()) {
+ centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8);
+ return;
+ }
+ for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
+ DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex);
+ String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(),
+ Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())};
+ double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7;
+ for (int column = 0; column < values.length; column++) {
+ centeredCell(stream, units, font, values[column], rowFontSize,
+ COLS[column] + .7, COLS[column + 1] - .7, baseline);
+ }
+ }
+ }
+
+ private String decimal(java.math.BigDecimal value) {
+ return value == null ? "0" : value.stripTrailingZeros().toPlainString();
+ }
+ private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException {
+ text(s, u, f, "入仓码:", 8, 14, 66);
+ List lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2);
+ for (int index = 0; index < lines.size(); index++) {
+ text(s, u, f, lines.get(index), 8, 33, 66 + index * 6);
+ }
+ }
+ private void metric(PDPageContentStream s, PdfUnits u, PDFont f, String label, String value,
+ double x1, double x2) throws IOException {
+ centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84);
+ centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91);
+ }
+ private void centered(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double centerX, double y) throws IOException {
+ float width = f.getStringWidth(value) / 1000 * size;
+ text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y);
+ }
+ private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double x1, double x2, double y) throws IOException {
+ String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
+ float width = f.getStringWidth(fitted) / 1000 * size;
+ text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y);
+ }
+ private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException {
+ String value = raw == null || raw.isBlank() ? "—" : raw;
+ if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value;
+ String ellipsis = "…";
+ int end = value.length();
+ while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--;
+ return end == 0 ? ellipsis : value.substring(0, end) + ellipsis;
+ }
+ private List wrap(PDFont font, String raw, float size, float maxWidth,
+ int maxLines) throws IOException {
+ String remaining = raw == null || raw.isBlank() ? "—" : raw;
+ List lines = new java.util.ArrayList<>();
+ while (!remaining.isEmpty() && lines.size() < maxLines) {
+ if (font.getStringWidth(remaining) / 1000 * size <= maxWidth) {
+ lines.add(remaining);
+ break;
+ }
+ int split = remaining.length();
+ while (split > 0 && font.getStringWidth(remaining.substring(0, split)) / 1000 * size > maxWidth) split--;
+ int delimiter = remaining.lastIndexOf('/', split - 1);
+ if (delimiter > 0) split = delimiter + 1;
+ String line = remaining.substring(0, Math.max(1, split));
+ remaining = remaining.substring(Math.max(1, split));
+ if (lines.size() == maxLines - 1 && !remaining.isEmpty()) line = fit(font, line + remaining, size, maxWidth);
+ lines.add(line);
+ }
+ return lines;
+ }
+ private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1,
+ double x2, double y2) throws IOException {
+ s.setNonStrokingColor(color);
+ s.addRect(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y2), PdfUnits.mmToPt(x2 - x1), PdfUnits.mmToPt(y2 - y1));
+ s.fill();
+ s.setNonStrokingColor(Color.BLACK);
+ }
+ private void text(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double x, double y) throws IOException {
+ s.beginText(); s.setFont(f, size); s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y));
+ s.showText(value == null ? "—" : value); s.endText();
+ }
+ private void line(PDPageContentStream s, PdfUnits u, double x1, double y1, double x2,
+ double y2) throws IOException {
+ s.moveTo(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y1));
+ s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2)); s.stroke();
+ }
+}
diff --git a/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java b/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java
new file mode 100644
index 000000000..86470581e
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java
@@ -0,0 +1,36 @@
+package com.baiyue.print.renderer;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.oned.Code128Writer;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.springframework.stereotype.Component;
+
+/** 将 Code 128 一维码以矢量矩形绘制到 PDF,避免打印缩放造成模糊。 */
+@Component
+public class PdfBarcodeRenderer {
+ private static final int QUIET_ZONE_MODULES = 10;
+
+ public BitMatrix encode(String content) {
+ try {
+ return new Code128Writer().encode(content, BarcodeFormat.CODE_128, 1, 1,
+ Map.of(EncodeHintType.MARGIN, QUIET_ZONE_MODULES));
+ } catch (IllegalArgumentException exception) {
+ throw new IllegalArgumentException("barcodeContent cannot be encoded", exception);
+ }
+ }
+
+ public void draw(PDPageContentStream stream, BitMatrix matrix, float leftPt,
+ float bottomPt, float widthPt, float heightPt) throws IOException {
+ float moduleWidth = widthPt / matrix.getWidth();
+ for (int x = 0; x < matrix.getWidth(); x++) {
+ if (matrix.get(x, 0)) {
+ stream.addRect(leftPt + x * moduleWidth, bottomPt, moduleWidth + .02f, heightPt);
+ }
+ }
+ stream.fill();
+ }
+}
diff --git a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java
new file mode 100644
index 000000000..b95a7d278
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java
@@ -0,0 +1,100 @@
+package com.baiyue.print.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.print.config.PrintPdfProperties;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.baiyue.print.renderer.DropshipPalletA4Renderer;
+import com.baiyue.print.renderer.PdfBarcodeRenderer;
+import com.google.zxing.common.BitMatrix;
+import jakarta.annotation.Resource;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Service;
+
+@Service
+public class DropshipPalletA4PdfService {
+ private static final BigDecimal A4_WIDTH = new BigDecimal("210");
+ private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
+ private static final int MAX_SKU_ROWS = 34;
+ private static final Set FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
+
+ @Resource private PrintPdfProperties properties;
+ @Resource private PdfGenerationLimiter limiter;
+ @Resource private DropshipPalletA4Renderer renderer;
+ @Resource private PdfBarcodeRenderer barcodeRenderer;
+
+ public GeneratedPdf generate(DropshipPalletA4PdfDtos.Request request) {
+ DropshipPalletA4PdfDtos.Request normalized = normalize(request);
+ try (PdfGenerationLimiter.Permit ignored = limiter.acquire(); PDDocument pdf = new PDDocument();
+ InputStream fontStream = new ClassPathResource(properties.getFontResource()).getInputStream();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDType0Font font = PDType0Font.load(pdf, fontStream, true);
+ for (DropshipPalletA4PdfDtos.Document document : normalized.documents()) {
+ BitMatrix barcode = barcodeRenderer.encode(document.barcodeContent());
+ renderer.renderPage(pdf, font, document, barcode);
+ }
+ pdf.save(output);
+ byte[] content = output.toByteArray();
+ if (content.length > properties.getMaxOutputBytes()) invalid("生成的 PDF 超过输出大小上限");
+ return new GeneratedPdf(content, normalized.downloadFileName(), normalized.documents().size());
+ } catch (IOException exception) {
+ throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
+ }
+ }
+
+ private DropshipPalletA4PdfDtos.Request normalize(DropshipPalletA4PdfDtos.Request request) {
+ if (request == null || !DropshipPalletA4PdfDtos.TEMPLATE.equals(line(request.templateCode()))) invalid("templateCode is not supported");
+ if (request.pageSpec() == null || request.pageSpec().widthMm() == null || request.pageSpec().heightMm() == null
+ || request.pageSpec().widthMm().compareTo(A4_WIDTH) != 0 || request.pageSpec().heightMm().compareTo(A4_HEIGHT) != 0) invalid("pageSpec must be A4");
+ String fileName = line(request.downloadFileName());
+ if (fileName.isBlank() || !fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || fileName.length() > 120
+ || fileName.chars().anyMatch(value -> FORBIDDEN_FILE_CHARS.contains((char) value))) invalid("downloadFileName is invalid");
+ if (request.documents() == null || request.documents().isEmpty() || request.documents().size() > properties.getMaxPages()) invalid("documents count is invalid");
+ List documents = new ArrayList<>();
+ for (int index = 0; index < request.documents().size(); index++) documents.add(normalizeDocument(request.documents().get(index), index));
+ return new DropshipPalletA4PdfDtos.Request(DropshipPalletA4PdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents));
+ }
+
+ private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
+ if (value == null) invalid("documents[" + index + "] is required");
+ if (value.cartonCount() < 0 || value.skuCount() < 0 || negative(value.weightKg()) || negative(value.volumeCbm())) invalid("documents[" + index + "] totals are invalid");
+ if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
+ List rows = new ArrayList<>();
+ for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) {
+ if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid");
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128),
+ text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity()));
+ }
+ return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300),
+ value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()),
+ barcode(value.barcodeContent()), List.copyOf(rows));
+ }
+
+ private BigDecimal decimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
+ private boolean negative(BigDecimal value) { return value != null && value.signum() < 0; }
+ private String barcode(String value) {
+ String normalized = text(value, 80);
+ if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only");
+ return normalized;
+ }
+ private String text(String value, int maxCodePoints) {
+ String normalized = line(value);
+ if (normalized.isBlank() || normalized.codePointCount(0, normalized.length()) > maxCodePoints
+ || normalized.codePoints().anyMatch(Character::isISOControl)) invalid("document text is invalid");
+ return normalized;
+ }
+ private String line(String value) { return value == null ? "" : Normalizer.normalize(value, Normalizer.Form.NFC).strip(); }
+ private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
+
+ public record GeneratedPdf(byte[] content, String fileName, int pageCount) {}
+}
diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml
index b44e93c8c..d71e12cd5 100644
--- a/print/src/main/resources/openapi/print-internal-v1.yaml
+++ b/print/src/main/resources/openapi/print-internal-v1.yaml
@@ -118,6 +118,27 @@ paths:
content:
application/pdf: { schema: { type: string, format: binary } }
application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
+ /internal/print/v1/pdfs/dropship-pallet-a4-documents:
+ post:
+ summary: Generate one A4 information sheet per dropship pallet
+ operationId: generateDropshipPalletA4Pdf
+ security: [{ internalServiceToken: [] }]
+ parameters: [{ $ref: '#/components/parameters/RequestId' }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: '#/components/schemas/DropshipPalletA4PdfRequest' } } }
+ responses:
+ '200':
+ description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
+ headers:
+ Content-Disposition: { schema: { type: string } }
+ X-Print-Page-Count: { schema: { type: integer, minimum: 1, maximum: 2000 } }
+ content:
+ application/pdf: { schema: { type: string, format: binary } }
+ application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
+ '413':
+ description: Request body exceeds the configured limit.
+ content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } }
components:
securitySchemes:
internalServiceToken:
@@ -254,6 +275,48 @@ components:
fbaShipmentId: { type: string }
poReferenceNo: { type: string }
packageCount: { type: integer, minimum: 0 }
+ DropshipPalletA4PdfRequest:
+ type: object
+ required: [templateCode, pageSpec, downloadFileName, documents]
+ properties:
+ templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] }
+ pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
+ downloadFileName: { type: string, maxLength: 120 }
+ documents:
+ type: array
+ minItems: 1
+ maxItems: 2000
+ description: One pallet renders as exactly one A4 page in request order.
+ items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
+ DropshipPalletA4Document:
+ type: object
+ required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows]
+ properties:
+ palletNo: { type: string, maxLength: 80 }
+ inboundCodes: { type: string, maxLength: 300 }
+ cartonCount: { type: integer, minimum: 0 }
+ skuCount: { type: integer, minimum: 0 }
+ weightKg: { type: number, minimum: 0 }
+ volumeCbm: { type: number, minimum: 0 }
+ barcodeContent:
+ type: string
+ maxLength: 80
+ pattern: '^[\\x20-\\x7E]+$'
+ description: Code 128 一维码内容,当前固定为稳定托盘码
+ skuRows:
+ type: array
+ minItems: 0
+ maxItems: 34
+ items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
+ DropshipPalletSkuRow:
+ type: object
+ required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity]
+ properties:
+ inboundCode: { type: string, maxLength: 80 }
+ skuCode: { type: string, maxLength: 128 }
+ cargoName: { type: string, maxLength: 200 }
+ cartonCount: { type: integer, minimum: 0 }
+ skuQuantity: { type: integer, minimum: 0 }
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java
index 9c059f7d9..798c269f4 100644
--- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java
+++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java
@@ -4,9 +4,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.FreightA4PdfDtos;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.DispatchBolPdfService;
+import com.baiyue.print.service.DropshipPalletA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -45,6 +47,8 @@ class InternalPdfControllerTest {
private FreightA4PdfService freightA4PdfService;
@MockBean
private DispatchBolPdfService dispatchBolPdfService;
+ @MockBean
+ private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource
private InternalPdfController controller;
private MockMvc mockMvc;
@@ -169,6 +173,27 @@ class InternalPdfControllerTest {
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
}
+ @Test
+ void returnsDropshipPalletA4PdfWithDownloadHeaders() throws Exception {
+ byte[] pdf = "%PDF-pallet-a4".getBytes(StandardCharsets.US_ASCII);
+ when(dropshipPalletA4PdfService.generate(any())).thenReturn(
+ new DropshipPalletA4PdfService.GeneratedPdf(pdf, "DP-260911-0034-A4托盘信息单.pdf", 1));
+
+ mockMvc.perform(post("/internal/print/v1/pdfs/dropship-pallet-a4-documents")
+ .header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
+ .content(validDropshipPalletA4RequestJson()))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(MediaType.APPLICATION_PDF))
+ .andExpect(content().bytes(pdf))
+ .andExpect(header().string("X-Print-Page-Count", "1"))
+ .andExpect(header().string("Content-Disposition",
+ org.hamcrest.Matchers.containsString("filename*=")));
+
+ verify(dropshipPalletA4PdfService).generate(any(DropshipPalletA4PdfDtos.Request.class));
+ }
+
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
@@ -185,11 +210,13 @@ class InternalPdfControllerTest {
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
+ .contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
+ .contains("DROPSHIP_PALLET_A4_V1")
.contains("additionalProperties: false")
.contains("minItems: 1")
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
@@ -238,4 +265,30 @@ class InternalPdfControllerTest {
""";
}
+ private String validDropshipPalletA4RequestJson() {
+ return """
+ {
+ "templateCode": "DROPSHIP_PALLET_A4_V1",
+ "pageSpec": {"widthMm": 210, "heightMm": 297},
+ "downloadFileName": "DP-260911-0034-A4托盘信息单.pdf",
+ "documents": [{
+ "palletNo": "DP-260911-0034",
+ "inboundCodes": "NYEXX2609110249",
+ "cartonCount": 3,
+ "skuCount": 1,
+ "weightKg": 90,
+ "volumeCbm": 0.534,
+ "barcodeContent": "DP-260911-0034",
+ "skuRows": [{
+ "inboundCode": "NYEXX2609110249",
+ "skuCode": "SKU-001",
+ "cargoName": "蓝牙音箱",
+ "cartonCount": 3,
+ "skuQuantity": 90
+ }]
+ }]
+ }
+ """;
+ }
+
}
diff --git a/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java b/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java
new file mode 100644
index 000000000..dfce28def
--- /dev/null
+++ b/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java
@@ -0,0 +1,22 @@
+package com.baiyue.print.renderer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.zxing.common.BitMatrix;
+import org.junit.jupiter.api.Test;
+
+class PdfBarcodeRendererTest {
+ @Test
+ void encodesCode128DeterministicallyWithQuietZone() {
+ PdfBarcodeRenderer renderer = new PdfBarcodeRenderer();
+
+ BitMatrix first = renderer.encode("DP-260911-0034");
+ BitMatrix second = renderer.encode("DP-260911-0034");
+
+ assertThat(first).isEqualTo(second);
+ assertThat(first.getHeight()).isEqualTo(1);
+ assertThat(first.getWidth()).isGreaterThan(100);
+ assertThat(first.getEnclosingRectangle()).isNotNull();
+ assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(5);
+ }
+}
diff --git a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java
new file mode 100644
index 000000000..3b7076e93
--- /dev/null
+++ b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java
@@ -0,0 +1,93 @@
+package com.baiyue.print.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.print.config.PrintPdfProperties;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.baiyue.print.renderer.DropshipPalletA4Renderer;
+import com.baiyue.print.renderer.PdfBarcodeRenderer;
+import com.baiyue.print.renderer.PdfUnits;
+import jakarta.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+@SpringJUnitConfig(classes = {DropshipPalletA4PdfService.class, PrintPdfProperties.class,
+ PdfGenerationLimiter.class, DropshipPalletA4Renderer.class, PdfBarcodeRenderer.class})
+class DropshipPalletA4PdfServiceGenerationTest {
+ @Resource private DropshipPalletA4PdfService service;
+
+ @Test
+ void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", "NYEXX2609110249", 3, 2,
+ new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034",
+ List.of(
+ new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40),
+ new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50)));
+ var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "DP-260911-0034-A4托盘信息单.pdf", List.of(document)));
+ assertThat(generated.pageCount()).isEqualTo(1);
+ assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ try (PDDocument pdf = Loader.loadPDF(generated.content())) {
+ assertThat(pdf.getNumberOfPages()).isEqualTo(1);
+ assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
+ assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
+ assertThat(new PDFTextStripper().getText(pdf))
+ .contains("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50")
+ .containsOnlyOnce("DP-260911-0034")
+ .doesNotContain("状态", "待封托", "已封托");
+ }
+ }
+
+ @Test
+ void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception {
+ List inboundCodes = new ArrayList<>();
+ for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index));
+ List rows = new ArrayList<>();
+ for (int index = 1; index <= 32; index++) {
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()),
+ "SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5));
+ }
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", String.join("/", inboundCodes), 64, 32,
+ new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows);
+ var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "DP-260911-0034-高密度预览.pdf", List.of(document)));
+ try (PDDocument pdf = Loader.loadPDF(generated.content())) {
+ assertThat(pdf.getNumberOfPages()).isEqualTo(1);
+ assertThat(new PDFTextStripper().getText(pdf))
+ .contains("NYEXX2609110241", "NYEXX2609110250", "SKU-001", "SKU-032")
+ .containsOnlyOnce("DP-260911-0034");
+ }
+ }
+
+ @Test
+ void rejectsSkuRowsThatCannotFitOnOneA4Page() {
+ List rows = new ArrayList<>();
+ for (int index = 0; index < 35; index++) {
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow("INBOUND", "SKU-" + index, "商品", 1, 1));
+ }
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", "INBOUND", 35, 35,
+ BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows);
+
+ assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "pallet.pdf", List.of(document))))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("one-page A4 capacity");
+ }
+}
From 774f0472e8b2e6d0df3b827b3ff60288352014f8 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 15:39:38 +0800
Subject: [PATCH 42/63] feat: explain supplemental pallet counts
---
lastleg-service/AGENTS.md | 2 +
.../ContainerInboundPlanPreviewResponse.java | 7 ++++
.../mapper/InboundPlanOrderMapper.java | 6 ++-
.../service/InboundPlanService.java | 23 +++++++++---
.../openapi/overseas-business-v1.yaml | 21 +++++++----
...rInboundPlanConversionCoordinatorTest.java | 37 ++++++++++---------
.../ContainerInboundPlanPreviewTest.java | 27 +++++++++++++-
7 files changed, 89 insertions(+), 34 deletions(-)
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index 202920809..7cf2c759c 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -18,6 +18,8 @@
人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
+柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。
+
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
| 功能 | 入口 |
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java
index 145aacd90..639b9851e 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java
@@ -17,6 +17,8 @@ public record ContainerInboundPlanPreviewResponse(
int blockedOrderCount,
int pendingOrderCount,
int pendingPalletCount,
+ int sourcePalletCount,
+ int supplementPalletCount,
int existingPlannedPalletCount,
int totalSuggestedPalletCount,
int totalPlannedPalletCount,
@@ -31,6 +33,8 @@ public record ContainerInboundPlanPreviewResponse(
int orderCount,
int pendingOrderCount,
int pendingPalletCount,
+ int sourcePalletCount,
+ int supplementPalletCount,
int existingPlannedPalletCount,
int suggestedPalletCount,
int plannedPalletCount,
@@ -52,6 +56,9 @@ public record ContainerInboundPlanPreviewResponse(
int packageCount,
BigDecimal volumeCbm,
Integer suggestedPalletCount,
+ int sourcePalletCount,
+ int targetPalletCount,
+ int supplementPalletCount,
Integer plannedPalletCount,
String existingPlanNo,
String planStatus,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java
index 19a6cee5f..35c6d97ed 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java
@@ -27,7 +27,7 @@ public interface InboundPlanOrderMapper {
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus,
- Integer plannedPalletCount, Integer materializedPalletCount
+ Integer plannedPalletCount, Integer materializedPalletCount, Integer sourcePalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
@@ -45,7 +45,9 @@ public interface InboundPlanOrderMapper {
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
- "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
+ "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," +
+ "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " +
+ "AND sp.order_id=o.id) AS sourcePalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List containerPreviewOrders(
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
index 765a95c13..f85c2c700 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
@@ -207,6 +207,10 @@ public class InboundPlanService {
items.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
+ items.stream().filter(InboundPlanService::requiresContainerConversion)
+ .mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum(),
+ items.stream().filter(InboundPlanService::requiresContainerConversion)
+ .mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
@@ -220,6 +224,10 @@ public class InboundPlanService {
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
+ int sourcePallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
+ .mapToInt(ContainerInboundPlanPreviewResponse.Order::sourcePalletCount).sum();
+ int supplementPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
+ .mapToInt(ContainerInboundPlanPreviewResponse.Order::supplementPalletCount).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -228,7 +236,7 @@ public class InboundPlanService {
bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
- pendingOrders, pendingPallets, existingPallets,
+ pendingOrders, pendingPallets, sourcePallets, supplementPallets, existingPallets,
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -258,6 +266,12 @@ public class InboundPlanService {
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
Integer suggested = suggestedPalletCount(row, blockers);
+ int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(row.orderType())
+ ? Objects.requireNonNullElse(suggested, 0)
+ : Objects.requireNonNullElse(row.sourcePalletCount(), 0);
+ int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
+ Objects.requireNonNullElse(suggested, 0));
+ int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
@@ -281,7 +295,8 @@ public class InboundPlanService {
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), fbaWarehouseCode, row.packageCount(), row.volumeCbm(),
- suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
+ suggested, sourcePalletCount, targetPalletCount, supplementPalletCount,
+ row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers));
}
@@ -321,9 +336,7 @@ public class InboundPlanService {
}
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
- return "RESOURCE_PENDING".equals(order.readiness())
- ? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
- : Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
+ return order.targetPalletCount();
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index d5038ae39..973f4d855 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -169,7 +169,7 @@ paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
- description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。
+ description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -190,7 +190,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}:
get:
summary: 查询尾程订单详情
- description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。
+ description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -2806,7 +2806,7 @@ components:
version: { type: integer }
ContainerInboundPlanPreview:
type: object
- required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups]
+ required: [sourceContainerBookingId, snapshotVersion, snapshotHash, totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, totalPlannedPalletCount, ready, groups]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true }
@@ -2819,7 +2819,9 @@ components:
readyOrderCount: { type: integer }
blockedOrderCount: { type: integer }
pendingOrderCount: { type: integer, description: 本次确认将新建计划的 READY 订单数 }
- pendingPalletCount: { type: integer, description: 本次确认将向资源服务批量预留的新托盘数 }
+ pendingPalletCount: { type: integer, description: 本次确认最终向资源服务批量预留的托盘总数,包含头程已有编码及海外补充编码 }
+ sourcePalletCount: { type: integer, description: 本次处理订单随头程装柜完成事件冻结并传入的托盘码数 }
+ supplementPalletCount: { type: integer, description: 最终托数超过头程已有托盘码数时,在海外仓连续补充的托盘码数 }
existingPlannedPalletCount: { type: integer, description: 已有有效计划的托盘总数,不计入本次新增 }
totalSuggestedPalletCount: { type: integer }
totalPlannedPalletCount: { type: integer }
@@ -2827,7 +2829,7 @@ components:
groups: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewGroup' } }
ContainerInboundPlanPreviewGroup:
type: object
- required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders]
+ required: [destinationType, orderCount, pendingOrderCount, pendingPalletCount, sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, suggestedPalletCount, plannedPalletCount, orders]
properties:
destinationType: { type: string, enum: [FBA, ONE_CLICK_DROPSHIP, PRIVATE_ADDRESS] }
destinationWarehouseId: { type: string, nullable: true }
@@ -2836,13 +2838,15 @@ components:
orderCount: { type: integer }
pendingOrderCount: { type: integer }
pendingPalletCount: { type: integer }
+ sourcePalletCount: { type: integer }
+ supplementPalletCount: { type: integer }
existingPlannedPalletCount: { type: integer }
suggestedPalletCount: { type: integer }
plannedPalletCount: { type: integer }
orders: { type: array, items: { $ref: '#/components/schemas/ContainerInboundPlanPreviewOrder' } }
ContainerInboundPlanPreviewOrder:
type: object
- required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, orderVersion, readiness, blockers]
+ required: [customerId, orderId, businessOrderNo, orderType, destinationType, packageCount, volumeCbm, sourcePalletCount, targetPalletCount, supplementPalletCount, orderVersion, readiness, blockers]
properties:
customerId: { type: string }
customerName: { type: string, nullable: true }
@@ -2858,11 +2862,14 @@ components:
packageCount: { type: integer }
volumeCbm: { type: number }
suggestedPalletCount: { type: integer, nullable: true }
+ sourcePalletCount: { type: integer, description: 头程已经冻结并传入的托盘码数 }
+ targetPalletCount: { type: integer, description: 本票最终生成的托盘总数,不少于头程已有托盘码数 }
+ supplementPalletCount: { type: integer, description: 本票需要在海外仓连续补充的托盘码数 }
plannedPalletCount: { type: integer, nullable: true }
existingPlanNo: { type: string, nullable: true }
planStatus: { type: string, nullable: true }
orderVersion: { type: integer, minimum: 0 }
- readiness: { type: string, enum: [READY, PLANNED, BLOCKED] }
+ readiness: { type: string, enum: [READY, RESOURCE_PENDING, PLANNED, BLOCKED] }
blockers: { type: array, items: { type: string } }
InboundPlanLine:
type: object
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java
index a4a843e15..122acef17 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java
@@ -87,7 +87,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
var ready = new ContainerInboundPlanPreviewResponse.Order(
"200", "Customer 200", "12", "LM-12", "RK-2", "FIRST_LEG", "FBA",
null, "ONT8", "Amazon ONT8", "ONT8", 10, BigDecimal.ONE, 1,
- null, null, null, 0, "READY", List.of());
+ 0, 1, 1, null, null, null, 0, "READY", List.of());
when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical);
when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID);
when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder(), ready)));
@@ -272,11 +272,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var fba = previewOrder("100", "11", "RK-FBA", "ABE3", "ABE3");
var overseas = previewOrder("200", "12", "RK-OVERSEAS", "US-LAX-01", null);
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
- "Los Angeles", 7L, SNAPSHOT_HASH, 2, 2, 0, 2, 2, 0, 2, 0, true,
+ "Los Angeles", 7L, SNAPSHOT_HASH, 2, 2, 0, 2, 2, 2, 0, 0, 2, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", null, "ABE3", "Amazon ABE3",
- 1, 1, 1, 0, 1, 0, List.of(fba)),
+ 1, 1, 1, 1, 0, 0, 1, 0, List.of(fba)),
new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles",
- 1, 1, 1, 0, 1, 0, List.of(overseas))));
+ 1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
.thenReturn("IP-FBA", "IP-OVERSEAS");
@@ -305,11 +305,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var overseas = new ContainerInboundPlanPreviewResponse.Order(
"200", "Customer 200", "12", "LM-12", "RK-OVERSEAS", "FIRST_LEG", "FIRST_LEG",
"31", "US-LAX-01", "Los Angeles", null, 10, BigDecimal.ONE, 2,
- null, null, null, 0, "READY", List.of());
+ 0, 2, 2, null, null, null, 0, "READY", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
- "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 2, 2, 0, 1, 0, true,
+ "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 2, 0, 2, 0, 1, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles",
- 1, 1, 2, 0, 2, 0, List.of(overseas))));
+ 1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS");
when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of());
@@ -337,11 +337,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
- null, null, null, 0, "READY", List.of());
+ 2, 3, 1, null, null, null, 0, "READY", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
- "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true,
+ "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 0, 3, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
- 1, 1, 3, 0, 3, 0, List.of(order))));
+ 1, 1, 3, 2, 1, 0, 3, 0, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
@@ -369,11 +369,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
var order = new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
- 3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
+ 2, 3, 1, 3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
- "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true,
+ "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 2, 1, 3, 3, 3, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
- 1, 1, 3, 3, 3, 3, List.of(order))));
+ 1, 1, 3, 2, 1, 3, 3, 3, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
@@ -404,7 +404,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
"LM-" + orderId, inboundCode, "ONE_CLICK_DROPSHIP", "ONE_CLICK_DROPSHIP",
fbaWarehouseCode == null ? "31" : null, destinationCode,
fbaWarehouseCode == null ? "Los Angeles" : "Amazon " + destinationCode,
- fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of());
+ fbaWarehouseCode, 10, BigDecimal.ONE, 1, 1, 1, 0,
+ null, null, null, 0, "READY", List.of());
}
private ManualInboundPlanRequest manualRequest() {
@@ -416,7 +417,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
return new ContainerInboundPlanPreviewResponse.Order(
"100", "Customer 100", "11", "LM-11", "RK-1", "FIRST_LEG", "FBA",
null, "ACY2", "Amazon ACY2", "ACY2", 10, BigDecimal.ONE, 1,
- 1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
+ 1, 1, 0, 1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
}
private ContainerInboundPlanPreviewResponse manualPreview(
@@ -425,9 +426,9 @@ class ContainerInboundPlanConversionCoordinatorTest {
|| "READY".equals(order.readiness())).count();
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending,
- 0, orders.size(), 1, true,
+ pending, 0, 0, orders.size(), 1, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
- orders.size(), pending, pending, 0, orders.size(), 1, orders)));
+ orders.size(), pending, pending, pending, 0, 0, orders.size(), 1, orders)));
}
private InboundPlanResponse plan(String planNo, List pallets) {
@@ -475,6 +476,6 @@ class ContainerInboundPlanConversionCoordinatorTest {
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
- "Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
+ "Los Angeles", version, hash, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, true, List.of());
}
}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java
index e308c5588..409c5f89b 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java
@@ -57,6 +57,8 @@ class ContainerInboundPlanPreviewTest {
assertFalse(result.ready());
assertEquals(4, result.pendingOrderCount());
assertEquals(7, result.pendingPalletCount());
+ assertEquals(7, result.sourcePalletCount());
+ assertEquals(0, result.supplementPalletCount());
assertEquals(2, result.existingPlannedPalletCount());
assertEquals(List.of("A-01", "B-02", "C-03", "DROPSHIP", ""), result.groups().stream()
.map(group -> group.destinationWarehouseCode() == null ? "" : group.destinationWarehouseCode()).toList());
@@ -74,7 +76,7 @@ class ContainerInboundPlanPreviewTest {
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
- null, null, null, null)));
+ null, null, null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
@@ -118,6 +120,27 @@ class ContainerInboundPlanPreviewTest {
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
}
+ @Test
+ void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
+ when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
+ new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1",
+ "测试客户", "FIRST_LEG", "FBA", "IN-1", "PENDING_ASSIGNMENT", null,
+ "MSCU6639870", 2, 250, new BigDecimal("5.3000"), "31", "US-LAX-01",
+ "洛杉矶仓", "ACY2", null, null, "Amazon ACY2", 0, false, 1,
+ null, null, null, null, 2)));
+
+ var result = service.previewContainer("BOOKING-SUPPLEMENT");
+ var order = result.groups().get(0).orders().get(0);
+
+ assertEquals(2, order.sourcePalletCount());
+ assertEquals(3, order.targetPalletCount());
+ assertEquals(1, order.supplementPalletCount());
+ assertEquals(2, result.sourcePalletCount());
+ assertEquals(1, result.supplementPalletCount());
+ assertEquals(3, result.pendingPalletCount());
+ assertEquals("READY", order.readiness());
+ }
+
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
@@ -137,6 +160,6 @@ class ContainerInboundPlanPreviewTest {
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
- planStatus, plannedPallets, materializedPallets);
+ planStatus, plannedPallets, materializedPallets, recommendedPallets);
}
}
From 071f807b87cd0050db2443dde17d12a1eb8ee5d5 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 15:45:05 +0800
Subject: [PATCH 43/63] fix: aggregate mixed cartons by fulfillment type
---
.../service/UnifiedOrderQueryService.java | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
index 779068add..e40838a64 100644
--- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
+++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
@@ -67,7 +67,7 @@ public class UnifiedOrderQueryService {
List orderIds = selected.stream().map(OrderEntity::getId).toList();
Map inboundByOrder = summaries(null, orderIds);
Map latestNotes = latestNotes(null, orderIds);
- Map cargoSummaries = cargoSummaries(null, orderIds);
+ Map cargoSummaries = cargoSummaries(null, selected);
Map childCounts = childCounts(selected);
return selected.stream().map(order -> row(order, inboundByOrder.get(order.getId()),
latestNotes.get(order.getId()), childCounts, cargoSummaries.get(order.getId()), null)).toList();
@@ -90,8 +90,7 @@ public class UnifiedOrderQueryService {
? optionalSummaries(tenantId, sourceOrderIds)
: summaries(tenantId, sourceOrderIds);
Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
- Map cargoSummaries = cargoSummaries(tenantId,
- source.records().stream().map(OrderEntity::getId).toList());
+ Map cargoSummaries = cargoSummaries(tenantId, source.records());
Map childCounts = childCounts(source.records());
Map coLoadByOrder = coLoadGroups(
source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups);
@@ -168,7 +167,7 @@ public class UnifiedOrderQueryService {
Map orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map latestNotes = latestNotes(tenantId, orderIds);
- Map cargoSummaries = cargoSummaries(tenantId, orderIds);
+ Map cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values()));
Map childCounts = childCounts(List.copyOf(orderById.values()));
Map coLoadByOrder = coLoadGroups(
orderIds, includeCoLoadGroups);
@@ -239,11 +238,15 @@ public class UnifiedOrderQueryService {
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
- private Map cargoSummaries(Long tenantId, List orderIds) {
- if (orderIds.isEmpty()) return Map.of();
+ private Map cargoSummaries(Long tenantId, List pageOrders) {
+ if (pageOrders.isEmpty()) return Map.of();
+ List orderIds = pageOrders.stream().map(OrderEntity::getId).toList();
+ Map fulfillmentTypes = new HashMap<>();
+ pageOrders.forEach(order -> fulfillmentTypes.put(order.getId(), order.getFulfillmentType()));
return orders.findCargoByOrderIds(tenantId, orderIds).stream()
- .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
- Collectors.collectingAndThen(Collectors.toList(), CargoSummary::from)));
+ .collect(Collectors.groupingBy(OrderCargoEntity::getOrderId)).entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ entry -> CargoSummary.from(entry.getValue(), fulfillmentTypes.get(entry.getKey()))));
}
private Map childCounts(List pageOrders) {
From e1319cb3890e59126a4a05b3cc7ce311402f7093 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 15:50:17 +0800
Subject: [PATCH 44/63] fix: keep unified SQL pass marker final
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index bb8add3c1..c3af9c533 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -19387,10 +19387,6 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_carrier_schedule_query
ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0);
-CALL baiyue_firstleg.assert_unified_final_schema();
-DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
-SELECT 'PASS' AS migration_status,
- '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
-- 退货预报关联尾程订单业务编号(测试阶段直接收敛当前结构)
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
DELIMITER $$
@@ -19406,3 +19402,8 @@ END$$
DELIMITER ;
CALL baiyue_lastleg.converge_return_order_tail_reference();
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
+
+CALL baiyue_firstleg.assert_unified_final_schema();
+DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
+SELECT 'PASS' AS migration_status,
+ '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
From 64fa620c193e8cb864c06ce320e2bf27c837813e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:02:10 +0800
Subject: [PATCH 45/63] test: add full return receipt acceptance
---
.../service/ReturnReceiptServiceTest.java | 14 +-
scripts/admin-test-env-acceptance.mjs | 4 +-
scripts/admin-test-env-modules/README.md | 1 +
...-overseas-return-receipt-contract.test.mjs | 48 +++
.../phase4-overseas-return-receipt.mjs | 310 ++++++++++++++++++
5 files changed, 375 insertions(+), 2 deletions(-)
create mode 100644 scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs
create mode 100644 scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java
index f4478ba23..28bcf59f7 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java
@@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -142,7 +143,18 @@ class ReturnReceiptServiceTest {
assertEquals(0, second.getReceivedQuantity());
assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, order.getStatus());
verify(inventory).createReceiptCarton(eq(order), any(ReturnReceipt.class), eq("RC-1"), any());
- verify(batches).insert(any(ReturnReceiptBatch.class));
+ ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
+ verify(receipts).insert(savedReceipt.capture());
+ ArgumentCaptor savedBatch = ArgumentCaptor.forClass(ReturnReceiptBatch.class);
+ verify(batches).insert(savedBatch.capture());
+ assertEquals(order.getId(), savedBatch.getValue().getReturnOrderId());
+ assertEquals(savedReceipt.getValue().getId(), savedBatch.getValue().getReceiptId());
+ assertEquals(order.getCustomerId(), savedBatch.getValue().getCustomerId());
+ assertEquals(order.getOverseasWarehouseId(), savedBatch.getValue().getOverseasWarehouseId());
+ assertEquals(result.receiptBatchNo(), savedBatch.getValue().getBatchNo());
+ assertEquals("PENDING_PRICING", savedBatch.getValue().getStorageBillingStatus());
+ assertNotNull(savedBatch.getValue().getStorageStartedAt());
+ assertNotNull(savedBatch.getValue().getCreatedAt());
}
@Test
diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs
index 6444347f4..4344c1e76 100644
--- a/scripts/admin-test-env-acceptance.mjs
+++ b/scripts/admin-test-env-acceptance.mjs
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
-const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
+const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
@@ -75,6 +75,7 @@ const MODULE_DEPENDENCIES = new Map([
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
['phase4-overseas-pda', ['phase4-tail-order-controls']],
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
+ ['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
@@ -333,6 +334,7 @@ export function parseArguments(argv) {
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
const requiresMarkerOwnedMerchantFixture = [
'phase4-overseas-business',
+ 'phase4-overseas-return-receipt',
'phase4-overseas-client-services',
'phase5-overseas-fulfillment',
'phase4-overseas-finance-cross-region',
diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md
index bce5f7498..526dd48b9 100644
--- a/scripts/admin-test-env-modules/README.md
+++ b/scripts/admin-test-env-modules/README.md
@@ -92,6 +92,7 @@ export async function run(context) {
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
+| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs
new file mode 100644
index 000000000..f8b2a421d
--- /dev/null
+++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs
@@ -0,0 +1,48 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
+const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
+
+test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
+ const source = await readFile(acceptanceUrl, 'utf8')
+ assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
+ assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/)
+ assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/)
+})
+
+test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => {
+ const source = await readFile(moduleUrl, 'utf8')
+ assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/)
+ assert.match(source, /\$\{PDA_RECEIPTS\}\/start/)
+ assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/)
+ assert.match(source, /PDA首次分批签收2件/)
+ assert.match(source, /PDA按SKU完成剩余3件签收/)
+ assert.match(source, /不存在SKU不是业务错误/)
+ assert.match(source, /已完成退货单不再进入待签收流程/)
+})
+
+test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => {
+ const source = await readFile(moduleUrl, 'utf8')
+ assert.match(source, /预报幂等重放复用同一退货单/)
+ assert.match(source, /签收重放不重复创建签收单、箱或计费批次/)
+ assert.match(source, /超收被拒绝且不得生成费用批次/)
+ assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/)
+ assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/)
+})
+
+test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => {
+ const source = await readFile(moduleUrl, 'utf8')
+ assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/)
+ assert.match(source, /receiptBatchNos/)
+ assert.match(source, /sourceBatchNo/)
+ assert.match(source, /PDA查询完成退货单的可合箱库存/)
+ assert.match(source, /PDA将同客户退货库存合为新箱/)
+ assert.match(source, /合箱重放复用同一新箱且不重复扣减/)
+})
+
+test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => {
+ const source = await readFile(moduleUrl, 'utf8')
+ assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
+})
diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs
new file mode 100644
index 000000000..3507217bf
--- /dev/null
+++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs
@@ -0,0 +1,310 @@
+import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
+
+const MODULE = 'phase4-overseas-return-receipt'
+const SCENARIO = 'phase4-overseas-return-receipt-and-billing'
+const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
+const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders'
+const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
+const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
+
+function id(value) { return value == null ? '' : String(value) }
+function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
+function code(response) { return String(response?.body?.code ?? '') }
+function success(response) { return response?.http === 200 && code(response) === '200' }
+function conflict(response) {
+ return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))))
+ && nonBlank(response?.requestId) && nonBlank(response?.traceId)
+}
+function notFound(response) {
+ return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response))))
+ && nonBlank(response?.requestId) && nonBlank(response?.traceId)
+}
+function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
+function trace(response) {
+ return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
+}
+function query(path, values) {
+ const params = new URLSearchParams()
+ for (const [name, value] of Object.entries(values || {})) {
+ if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
+ }
+ return params.size ? `${path}?${params}` : path
+}
+function key(context, action) {
+ return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255)
+}
+function markerSuffix(context) {
+ return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
+}
+
+function add(context, name, response, passed, evidence = '') {
+ context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
+}
+
+async function overseas(context, token, method, name, path, body = undefined) {
+ const response = await context.requestOverseas(method, path, body, {}, token)
+ add(context, name, response, success(response))
+ return { response, data: success(response) ? response.body?.data : null }
+}
+
+async function resolveWarehouse(context) {
+ const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
+ const response = await context.request('GET',
+ '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
+ const rows = records(response?.body?.data)
+ const fixedIds = new Set(['193000000000200003', '193000000000200004'])
+ const warehouse = rows.find((item) => id(item?.id) === existing)
+ || rows.find((item) => fixedIds.has(id(item?.id)))
+ || rows[0]
+ add(context, '读取可执行退货签收的海外仓', response,
+ success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
+ return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
+}
+
+async function loginMerchant(context) {
+ const fixture = context.context?.merchantQualificationFixture
+ if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) {
+ add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`)
+ return null
+ }
+ const response = await context.request('POST', '/api/auth/login', {
+ loginId: fixture.username, password: fixture.password,
+ }, '')
+ const token = success(response) ? response.body?.data?.overseasAccessToken : null
+ add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
+ return nonBlank(token) ? token : null
+}
+
+function itemMap(items) {
+ return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item]))
+}
+
+export async function run(context) {
+ context.createdResources.phase4OverseasReturnReceipt = {
+ marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
+ billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
+ }
+ const merchantToken = await loginMerchant(context)
+ const warehouse = await resolveWarehouse(context)
+ const access = await resolvePickerAccess(context)
+ if (!merchantToken || !warehouse || !access) return
+ const picker = await createPicker(context, 91, warehouse.id, access)
+ if (!picker?.token) {
+ add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
+ if (picker?.membershipId) await disablePicker(context, picker, 91)
+ return
+ }
+
+ const suffix = markerSuffix(context)
+ const skuA = `RET-A-${suffix}`.slice(0, 128)
+ const skuB = `RET-B-${suffix}`.slice(0, 128)
+ const createBody = {
+ overseasWarehouseId: warehouse.id,
+ warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000',
+ customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker,
+ idempotencyKey: key(context, 'CREATE'),
+ items: [
+ { skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 },
+ { skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 },
+ ],
+ }
+
+ try {
+ const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody)
+ const returnNo = id(created.data?.order?.returnNo)
+ context.assert(MODULE, '退货预报初始数量和状态正确', created.response,
+ nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT'
+ && Number(created.data?.order?.expectedQuantity) === 5
+ && Number(created.data?.order?.receivedQuantity) === 0
+ && itemMap(created.data?.items).size === 2,
+ `returnNo=${returnNo || 'missing'}`, OPTIONS)
+ if (!returnNo) return
+ context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo
+
+ const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
+ context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
+ id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
+ const createConflict = await context.requestOverseas('POST', RETURN_ORDERS,
+ { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)
+ add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict))
+
+ const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
+ `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
+ context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response,
+ success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0,
+ trace(emptySku.response), OPTIONS)
+
+ const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收',
+ `${PDA_RECEIPTS}/start`, { returnNo })
+ context.assert(MODULE, '首次开始返回全部未收SKU', started.response,
+ started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2,
+ trace(started.response), OPTIONS)
+ const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单',
+ `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
+ context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response,
+ skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo
+ && Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0),
+ trace(skuCandidates.response), OPTIONS)
+
+ const cartonA = `RC-${suffix}-A`.slice(0, 128)
+ const firstBody = {
+ returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA,
+ items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'),
+ }
+ const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody)
+ context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response,
+ first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false
+ && Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA
+ && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo),
+ `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
+ const firstReceiptNo = id(first.data?.receiptNo)
+ const firstBatchNo = id(first.data?.receiptBatchNo)
+
+ const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody)
+ context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response,
+ id(firstReplay.data?.receiptNo) === firstReceiptNo
+ && id(firstReplay.data?.receiptBatchNo) === firstBatchNo
+ && id(firstReplay.data?.cartonMark) === cartonA,
+ trace(firstReplay.response), OPTIONS)
+ const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
+ ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
+ }, {}, picker.token)
+ add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict))
+
+ const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
+ returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }],
+ idempotencyKey: key(context, 'OVER-RECEIPT'),
+ }, {}, picker.token)
+ add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt))
+
+ const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
+ `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
+ context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response,
+ afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED'
+ && Number(afterPartial.data?.order?.receivedQuantity) === 2
+ && afterPartial.data?.receipts?.length === 1
+ && id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo,
+ `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS)
+
+ const secondBody = {
+ returnNo, startedBy: 'SKU',
+ items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }],
+ idempotencyKey: key(context, 'RECEIPT-2'),
+ }
+ const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
+ context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response,
+ second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true
+ && Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark)
+ && nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo)
+ && id(second.data?.receiptBatchNo) !== firstBatchNo,
+ `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
+ const secondReceiptNo = id(second.data?.receiptNo)
+ const secondBatchNo = id(second.data?.receiptBatchNo)
+ const cartonB = id(second.data?.cartonMark)
+ context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo)
+ context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo)
+ context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
+
+ const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)
+ add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart))
+ const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
+ `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
+ context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
+ !completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo),
+ trace(completedCandidates.response), OPTIONS)
+
+ const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收',
+ `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
+ const detailItems = itemMap(detail.data?.items)
+ const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : []
+ const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo)))
+ context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response,
+ detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5
+ && Number(detailItems.get(skuA)?.receivedQuantity) === 3
+ && Number(detailItems.get(skuB)?.receivedQuantity) === 2
+ && detailReceipts.length === 2 && detailBatches.size === 2
+ && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
+ `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
+
+ const merchantInventory = await context.requestOverseas('GET',
+ query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }),
+ undefined, {}, merchantToken)
+ add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
+
+ const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
+ const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存',
+ query('/api/platform/v1/last-leg/return-inventory/cartons', {
+ page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
+ }))
+ const inventoryRows = records(inventory.data)
+ const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo)))
+ context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response,
+ inventoryRows.length === 2
+ && inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5
+ && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
+ `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
+
+ const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存',
+ query('/api/platform/v1/last-leg/return-inventory', {
+ page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
+ }))
+ const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode))
+ .map((row) => [row.skuCode, row]))
+ context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response,
+ Number(summaries.get(skuA)?.totalQuantity) === 3
+ && Number(summaries.get(skuB)?.totalQuantity) === 2,
+ `skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS)
+
+ for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 },
+ { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) {
+ const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`,
+ `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
+ context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
+ carton.data?.sourceBatchNo === expected.batchNo
+ && Number(carton.data?.totalQuantity) === expected.quantity
+ && Array.isArray(carton.data?.items) && carton.data.items.length > 0,
+ `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
+ const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`,
+ `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
+ context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
+ label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
+ && Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml),
+ trace(label.response), OPTIONS)
+ }
+
+ const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存',
+ query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo }))
+ const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row]))
+ context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response,
+ packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED')
+ && Number(packingSkus.get(skuA)?.availableQuantity) === 3
+ && Number(packingSkus.get(skuB)?.availableQuantity) === 2,
+ trace(packing.response), OPTIONS)
+
+ const mergeBody = {
+ returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }],
+ length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'),
+ }
+ const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱',
+ '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
+ if (success(merged.response)) {
+ context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
+ nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
+ && merged.data?.label?.sourceBatchNo === 'MERGED'
+ && Number(merged.data?.label?.items?.length) === 2
+ && nonBlank(merged.data?.label?.printableHtml),
+ `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
+ const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
+ '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
+ context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
+ success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
+ && id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
+ const mergeConflict = await context.requestOverseas('POST',
+ '/api/pda/v1/last-leg/return-inventory/cartons/merge',
+ { ...mergeBody, weight: 3 }, {}, picker.token)
+ add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
+ }
+ } finally {
+ await disablePicker(context, picker, 91)
+ }
+}
From 2d928d667b4aaf3bc360dd5fc41d9c0660898141 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:10:49 +0800
Subject: [PATCH 46/63] fix: resolve cross-plan pallet allocations
---
.../inboundplan/dto/InboundPlanResponse.java | 3 +-
.../service/InboundPlanService.java | 37 +++++++++++++++++-
.../openapi/overseas-business-v1.yaml | 3 ++
.../service/InboundPlanServiceTest.java | 38 +++++++++++++++++++
4 files changed, 78 insertions(+), 3 deletions(-)
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java
index d20d7e549..f36ce2706 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java
@@ -49,7 +49,8 @@ public record InboundPlanResponse(
int actualPackageCount) {
}
- public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
+ public record Allocation(Long lineId, Long palletId, String palletNo, String palletOwnerPlanNo,
+ boolean palletOwnedByCurrentPlan, BigDecimal allocatedVolumeCbm,
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, String status, int version) {
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
index f85c2c700..930b74881 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java
@@ -1026,6 +1026,34 @@ public class InboundPlanService {
new LambdaQueryWrapper()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
+ List referencedPalletIds = allocations.stream()
+ .map(InboundPlanPalletAllocation::getPalletId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ Map referencedPallets = referencedPalletIds.isEmpty()
+ ? Map.of()
+ : palletMapper.selectBatchIds(referencedPalletIds).stream()
+ .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet));
+ if (referencedPallets.size() != referencedPalletIds.size()) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘不存在");
+ }
+ List externalOwnerPlanIds = referencedPallets.values().stream()
+ .map(InboundPlanPallet::getPlanId)
+ .filter(Objects::nonNull)
+ .filter(ownerPlanId -> !Objects.equals(ownerPlanId, plan.getId()))
+ .distinct()
+ .toList();
+ Map palletOwnerPlanNos = new HashMap<>();
+ palletOwnerPlanNos.put(plan.getId(), plan.getPlanNo());
+ if (!externalOwnerPlanIds.isEmpty()) {
+ palletOwnerPlanNos.putAll(planMapper.selectBatchIds(externalOwnerPlanIds).stream()
+ .collect(Collectors.toMap(InboundPlan::getId, InboundPlan::getPlanNo)));
+ }
+ if (referencedPallets.values().stream().anyMatch(pallet ->
+ pallet.getPlanId() == null || !palletOwnerPlanNos.containsKey(pallet.getPlanId()))) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划分配引用的托盘所属计划不存在");
+ }
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
sourceOrder == null ? null : sourceOrder.orderNo(),
sourceOrder == null ? null : sourceOrder.customerName(),
@@ -1043,9 +1071,14 @@ public class InboundPlanService {
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
- allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
+ allocations.stream().map(item -> {
+ InboundPlanPallet referencedPallet = referencedPallets.get(item.getPalletId());
+ return new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
+ referencedPallet.getPalletNo(), palletOwnerPlanNos.get(referencedPallet.getPlanId()),
+ Objects.equals(referencedPallet.getPlanId(), plan.getId()),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
- item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
+ item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion());
+ }).toList(),
plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(),
Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()),
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark(),
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index 973f4d855..2038a62d9 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -2908,6 +2908,9 @@ components:
properties:
lineId: { type: string, pattern: '^[0-9]+$' }
palletId: { type: string, pattern: '^[0-9]+$' }
+ palletNo: { type: string, description: 分配实际引用的托盘码,跨计划合托时仍返回真实托盘码 }
+ palletOwnerPlanNo: { type: string, description: 托盘所属入库计划号 }
+ palletOwnedByCurrentPlan: { type: boolean, description: 托盘是否归属当前详情入库计划 }
allocatedVolumeCbm: { type: number }
plannedPackageCount: { type: integer }
countedPackageCount: { type: integer }
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java
index 2da5db8c9..523c4e2d0 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java
@@ -334,6 +334,44 @@ class InboundPlanServiceTest {
verify(orderMapper).findDetailOrder("100", 1L);
}
+ @Test
+ void detailResolvesCrossPlanMergedPalletWithoutChangingOwnedPalletList() {
+ InboundPlan currentPlan = existingPlan("100", 1L, "IP-CURRENT");
+ InboundPlan ownerPlan = existingPlan("100", 2L, "IP-OWNER");
+ InboundPlanPallet ownedPallet = new InboundPlanPallet();
+ ownedPallet.setId(301L);
+ ownedPallet.setPlanId(currentPlan.getId());
+ ownedPallet.setPalletNo("DRYU2309389-101-005");
+ ownedPallet.setSequenceNo(1);
+ ownedPallet.setActualPackageCount(0);
+ InboundPlanPallet mergedPallet = new InboundPlanPallet();
+ mergedPallet.setId(302L);
+ mergedPallet.setPlanId(ownerPlan.getId());
+ mergedPallet.setPalletNo("DRYU2309389-101-006");
+ InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
+ allocation.setLineId(201L);
+ allocation.setPalletId(302L);
+ allocation.setPlannedPackageCount(150);
+ allocation.setCountedPackageCount(150);
+ allocation.setPickedPackageCount(150);
+ allocation.setVersion(0);
+
+ when(planMapper.selectOne(any())).thenReturn(currentPlan);
+ when(palletMapper.selectList(any())).thenReturn(List.of(ownedPallet));
+ when(allocationMapper.selectList(any())).thenReturn(List.of(allocation));
+ when(palletMapper.selectBatchIds(any())).thenReturn(List.of(mergedPallet));
+ when(planMapper.selectBatchIds(any())).thenReturn(List.of(ownerPlan));
+
+ var result = service.detailByPlanNo("100", "IP-CURRENT");
+
+ assertEquals(List.of("DRYU2309389-101-005"),
+ result.pallets().stream().map(pallet -> pallet.palletNo()).toList());
+ assertEquals("DRYU2309389-101-006", result.allocations().get(0).palletNo());
+ assertEquals("IP-OWNER", result.allocations().get(0).palletOwnerPlanNo());
+ assertEquals(false, result.allocations().get(0).palletOwnedByCurrentPlan());
+ assertEquals(150, result.allocations().get(0).pickedPackageCount());
+ }
+
@Test
void detailFailsClosedWhenReceivingWarehouseFactIsMissing() {
InboundPlan plan = existingPlan("100", 1L, "LIP-1");
From 51ce609ab19cf25add4f4fcf09bb4e182843b831 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:17:16 +0800
Subject: [PATCH 47/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../auth/client/IamOnboardingClient.java | 6 +
.../auth/dto/MerchantRegisterRequest.java | 2 +-
.../service/MerchantRegistrationService.java | 33 +++-
.../MerchantRegistrationServiceTest.java | 44 +++++
deploy/sql/头程数据库统一增量-MySQL8.sql | 1 +
.../service/LocalQualificationStorage.java | 19 +-
.../qualification-required-package-v1.yaml | 6 +-
...cationAndOrderFileOpenApiContractTest.java | 2 +-
.../LocalQualificationStorageTest.java | 43 ++++-
.../firstleg/client/PrintPdfClient.java | 1 +
.../domain/PriceBookChannelCatalog.java | 9 +
.../mapper/LoadingListQueryMapper.java | 5 +
.../service/OceanBookingPrintService.java | 24 +--
.../service/InboundMeasurementService.java | 2 +-
.../firstleg/service/LoadingListService.java | 16 +-
.../service/MeasurementReviewService.java | 8 +-
.../main/resources/openapi/firstleg-v1.yaml | 4 +-
.../resources/openapi/ocean-bookings-v1.yaml | 4 +-
.../openapi/phase3-container-overview-v1.yaml | 4 +-
...easurementEvidenceOpenApiContractTest.java | 6 +-
.../service/OceanBookingPrintServiceTest.java | 19 +-
.../InboundMeasurementServiceTest.java | 6 +-
.../service/LoadingListServiceTest.java | 44 +++++
.../service/MeasurementReviewServiceTest.java | 35 ++++
.../controller/InternalTenantController.java | 10 +-
.../controller/PlatformTenantController.java | 2 +-
.../controller/ProfileResourceController.java | 34 +++-
.../com/baiyue/iam/dto/OnboardingDtos.java | 7 +-
.../java/com/baiyue/iam/dto/ProfileDtos.java | 11 ++
.../java/com/baiyue/iam/dto/TenantDtos.java | 6 +-
.../iam/entity/IamTenantQualification.java | 4 +-
.../repository/ProfileResourceRepository.java | 12 ++
.../iam/repository/TenantRepository.java | 2 +
.../service/MerchantOnboardingService.java | 28 ++-
.../iam/service/ProfileResourceService.java | 74 +++++++-
.../com/baiyue/iam/service/TenantService.java | 10 +-
iam/src/main/resources/db/iam.sql | 1 +
iam/src/main/resources/openapi/iam-v1.yaml | 164 ++++++++++++++++--
.../controller/IamOpenApiContractTest.java | 6 +
.../PlatformCustomerResourceContractTest.java | 17 ++
.../PlatformTenantControllerContractTest.java | 2 +-
.../ArchivedCustomerListContractTest.java | 19 ++
.../QualificationSchemaContractTest.java | 26 +++
.../MerchantOnboardingServiceTest.java | 47 +++++
.../MerchantQualificationUploadTest.java | 73 +++++++-
...PlatformQualificationRegistrationTest.java | 4 +-
.../lastleg/dto/LastlegOrderSummary.java | 3 +-
.../baiyue/lastleg/entity/LastlegOrder.java | 1 -
.../lastleg/service/LastlegOrderService.java | 41 ++++-
.../service/LoadingCompletedConsumer.java | 2 +-
.../src/main/resources/db/lastleg.sql | 2 -
.../service/LastlegOrderServiceTest.java | 24 +++
.../controller/InternalPdfController.java | 1 +
scripts/admin-test-env-acceptance.mjs | 6 +
.../inbound-measurement.mjs | 79 ++++++---
scripts/admin-test-env-modules/orders.mjs | 16 +-
scripts/agents-index-contract.ps1 | 4 +-
57 files changed, 942 insertions(+), 139 deletions(-)
create mode 100644 iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
index bb60517ca..aae981b02 100644
--- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
+++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -16,6 +17,11 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request);
+ @GetMapping("/merchant-registrations/{authUserId}")
+ ApiResponse registration(
+ @RequestHeader("X-Internal-Service-Token") String internalToken,
+ @PathVariable("authUserId") Long authUserId);
+
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
index 8203a100e..d0ca9eb4c 100644
--- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
+++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
@@ -12,7 +12,7 @@ public record MerchantRegisterRequest(
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName,
- @NotBlank @Size(max = 64) String legalPerson,
+ @Size(max = 64) String legalPerson,
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail,
diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
index 1ce404873..e17c5410e 100644
--- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
+++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
@@ -35,11 +35,20 @@ public class MerchantRegistrationService {
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
- if (accountRepository.existsByUsername(request.username())) {
+ AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
+ AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
+ if (usernameAccount != null && phoneAccount != null
+ && usernameAccount.getId().equals(phoneAccount.getId())
+ && "MERCHANT".equals(usernameAccount.getDefaultAppCode())
+ && "ACTIVE".equals(usernameAccount.getAccountStatus())
+ && passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
+ return existingRegistration(usernameAccount);
+ }
+ if (usernameAccount != null) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
- if (accountRepository.existsByPhone(request.phone())) {
- throw new BizException(ResultCode.CONFLICT, "手机号已存在");
+ if (phoneAccount != null) {
+ throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
@@ -111,6 +120,24 @@ public class MerchantRegistrationService {
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
}
+ private MerchantRegisterResponse existingRegistration(AuthAccount account) {
+ try {
+ var response = onboardingClient.registration(internalToken, account.getId());
+ if (response == null || !response.successful() || response.data() == null) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
+ }
+ var onboarding = response.data();
+ if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
+ throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
+ }
+ return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
+ onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
+ "LOGIN_AND_COMPLETE_QUALIFICATIONS");
+ } catch (FeignException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
+ }
+ }
+
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return;
diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
index b09d43d33..7eb495634 100644
--- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
+++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
@SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@@ -61,6 +62,49 @@ class MerchantRegistrationServiceTest {
assertEquals("PENDING", result.dimensionInitializationStatus());
}
+ @Test
+ void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
+ AuthAccount account = new AuthAccount();
+ account.setId(9001L);
+ account.setUsername("registered_owner");
+ account.setPhone("13800138000");
+ account.setPasswordHash("encoded-password");
+ account.setAccountStatus("ACTIVE");
+ account.setDefaultAppCode("MERCHANT");
+ when(accounts.findByUsername("registered_owner")).thenReturn(account);
+ when(accounts.findByPhone("13800138000")).thenReturn(account);
+ when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
+ when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
+ new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
+
+ var result = service.register(new MerchantRegisterRequest(
+ "registered_owner", "Password123", "注册人", "测试企业", null,
+ "13800138000", "owner@example.com", "CN", "深圳市宝安区"));
+
+ assertEquals(9001L, result.accountId());
+ assertEquals(10001L, result.tenantId());
+ assertEquals("READY", result.dimensionInitializationStatus());
+ verify(onboarding, never()).register(eq("internal-token"), any());
+ verify(accounts, never()).insert(any());
+ }
+
+ @Test
+ void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
+ AuthAccount account = new AuthAccount();
+ account.setId(9001L);
+ account.setUsername("registered_owner");
+ account.setPhone("13800138000");
+ when(accounts.findByPhone("13800138000")).thenReturn(account);
+
+ var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
+ () -> service.register(new MerchantRegisterRequest(
+ "another_owner", "Password123", "注册人", "测试企业", null,
+ "13800138000", "owner@example.com", "CN", "深圳市宝安区")));
+
+ assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
+ verify(onboarding, never()).register(eq("internal-token"), any());
+ }
+
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index c3af9c533..c1f6591d6 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -411,6 +411,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
+ original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
index 6d872dc02..8885e0be6 100644
--- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
+++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
@@ -25,7 +25,8 @@ import java.util.UUID;
public class LocalQualificationStorage {
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
private static final long MAX_SIZE = 5L * 1024 * 1024;
- private static final Set TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
+ private static final Set TYPES = Set.of(
+ "image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf");
@Value("${baiyue.file.storage-root}") private String configuredRoot;
private Path root;
private Path qualificationRoot;
@@ -53,11 +54,16 @@ public class LocalQualificationStorage {
if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件");
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
- if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
+ if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件");
if (!matchesSignature(file, contentType)) {
throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配");
}
- String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg");
+ String extension = switch (contentType) {
+ case "image/png" -> ".png";
+ case "image/bmp", "image/x-ms-bmp" -> ".bmp";
+ case "application/pdf" -> ".pdf";
+ default -> ".jpg";
+ };
String fileName = UUID.randomUUID() + extension;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
@@ -92,6 +98,9 @@ public class LocalQualificationStorage {
return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2d;
}
+ if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) {
+ return read >= 2 && header[0] == 0x42 && header[1] == 0x4d;
+ }
return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50
&& header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d
&& header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a;
@@ -101,7 +110,7 @@ public class LocalQualificationStorage {
}
public Resource load(long tenantId, String fileName) {
- if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
+ if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) {
throw fileNotFound();
}
Path directory = tenantDirectory(tenantId);
@@ -124,7 +133,7 @@ public class LocalQualificationStorage {
String prefix = "/api/merchant/v1/files/qualifications/";
if (!documentUrl.startsWith(prefix)) return;
String fileName = documentUrl.substring(prefix.length());
- if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return;
+ if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) return;
diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml
index 1316db9eb..99e4d0112 100644
--- a/file/src/main/resources/openapi/qualification-required-package-v1.yaml
+++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml
@@ -29,7 +29,7 @@ paths:
type: string
format: binary
maxLength: 5242880
- description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致
+ description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致
responses:
'200':
description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId
@@ -139,12 +139,12 @@ components:
businessLicense:
type: string
format: binary
- description: 营业执照,必填;PDF/JPG/PNG,最大5MB
+ description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB
legalPersonId:
type: string
format: binary
nullable: true
- description: 法人身份证,选填;PDF/JPG/PNG,最大5MB
+ description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB
responses:
Error:
description: 认证或授权失败,响应包含请求关联元数据
diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java
index 948b3eef3..0717cd6ce 100644
--- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java
+++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java
@@ -16,7 +16,7 @@ class QualificationAndOrderFileOpenApiContractTest {
assertThat(yaml)
.contains("/api/merchant/v1/files/qualifications:")
.contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]")
- .contains("merchant:qualification:upload", "maxLength: 5242880")
+ .contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP")
.contains("name: replace", "requestId:", "traceId:")
.doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]");
}
diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
index 9fd947bf1..e08d03c5c 100644
--- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
+++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
@@ -43,7 +43,7 @@ class LocalQualificationStorageTest {
Path qualificationRoot = storageRoot.resolve("qualifications");
assertTrue(Files.isDirectory(qualificationRoot));
try (var files = Files.list(qualificationRoot)) {
- assertEquals(0, files.count());
+ assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-")));
}
}
@@ -78,6 +78,47 @@ class LocalQualificationStorageTest {
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
}
+ @Test
+ void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception {
+ storage.initialize();
+ byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+ for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) {
+ var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp);
+ var stored = storage.store(10001L, upload);
+
+ assertTrue(stored.fileName().endsWith(".bmp"));
+ assertEquals(stored.path().toAbsolutePath(),
+ storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
+ BizException crossTenant = assertThrows(BizException.class,
+ () -> storage.load(10002L, stored.fileName()));
+ assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode());
+ }
+ }
+
+ @Test
+ void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() {
+ storage.initialize();
+ var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes());
+
+ BizException exception = assertThrows(BizException.class,
+ () -> storage.store(10001L, upload));
+
+ assertTrue(exception.getMessage().contains("文件内容与格式不匹配"));
+ }
+
+ @Test
+ void deletesBmpQualificationByItsValidatedDocumentUrl() {
+ storage.initialize();
+ byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
+ var stored = storage.store(10001L,
+ new MockMultipartFile("file", "license.bmp", "image/bmp", bmp));
+
+ storage.deleteByDocumentUrl(10001L, stored.documentUrl());
+
+ assertFalse(Files.exists(stored.path()));
+ }
+
@Test
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
storage.initialize();
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
index 1275279de..ed265fbca 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
@@ -60,4 +60,5 @@ public interface PrintPdfClient {
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
+
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java
index 0766bf4f9..2504fbd92 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java
@@ -1,12 +1,16 @@
package com.baiyue.firstleg.domain;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {}
+ private static final Set SEA_RAIL_CHANNEL_CODES = Set.of(
+ "EXX_RAIL_NY", "NYEXX", "OA_CHICAGO_RAIL", "CPHT");
+
public static final List CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"),
@@ -37,6 +41,11 @@ public final class PriceBookChannelCatalog {
inboundCodePrefix, overseasInboundCodePrefix);
}
+ public static boolean isSeaRail(String channelCode) {
+ return channelCode != null
+ && SEA_RAIL_CHANNEL_CODES.contains(channelCode.trim().toUpperCase(Locale.ROOT));
+ }
+
public record ChannelDefinition(
String channelCode,
String displayName,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
index 23ff26071..48d6f47cb 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
@@ -23,6 +23,7 @@ public interface LoadingListQueryMapper {
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
+ b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
@@ -51,6 +52,7 @@ public interface LoadingListQueryMapper {
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
+ b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type
""";
@@ -394,6 +396,9 @@ public interface LoadingListQueryMapper {
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
+ private String channelCode;
+ private LocalDateTime outboundConfirmedAt;
+ private LocalDateTime actualEmptyReportedAt;
private String vesselName;
private String voyageNo;
private String containerType;
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
index 1b98b7535..ca7a1b1dd 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java
@@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
-import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
@@ -47,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
- fields(template, detail), List.of(cargo(detail.containerRequirements())))));
+ fields(template, detail), List.of(emptyCargo()))));
try {
ResponseEntity response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
@@ -78,12 +77,12 @@ public class OceanBookingPrintService {
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
- fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
+ fields.put("totalPackagesInWords", "");
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
- fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
+ fields.put("cargoQuantity", "");
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
@@ -99,18 +98,8 @@ public class OceanBookingPrintService {
return fields;
}
- private static PrintPdfClient.CargoRow cargo(List requirements) {
- BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
- .multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
- BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
- .multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
- return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
- "CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
- decimal(volume, 4) + " CBM");
- }
-
- private static int totalContainers(List values) {
- return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
+ private static PrintPdfClient.CargoRow emptyCargo() {
+ return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
}
private static String requirementSummary(List values) {
@@ -128,9 +117,6 @@ public class OceanBookingPrintService {
private static String date(java.time.LocalDateTime value) {
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
- private static String decimal(BigDecimal value, int scale) {
- return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
- }
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
private static String text(String value) { return value == null ? "" : value.trim(); }
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
index b8c26a5e2..6f84a362f 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
@@ -261,7 +261,7 @@ public class InboundMeasurementService {
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
- "请至少上传并确认一张称重图片后再提交测量审核");
+ "即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items);
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java
index d1f32c420..182cdcfa3 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java
@@ -26,6 +26,7 @@ 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.PriceBookChannelCatalog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -138,7 +139,9 @@ public class LoadingListService {
}
public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); }
public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){
- requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
+ requireLogisticsType(type); String businessType=fileType(type); AggregateRow plan=requireBookingPlan(bookingId);
+ requireDeliveryOrderUploadAllowed(plan);
+ return uploadDocument(plan,type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
}
public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){
requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在");
@@ -190,6 +193,17 @@ public class LoadingListService {
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
+ private static void requireDeliveryOrderUploadAllowed(AggregateRow plan) {
+ if (!"COMPLETED".equals(plan.getPlanStatus()) || plan.getOutboundConfirmedAt() == null) {
+ throw conflict("货柜进入海外履约后才可上传DO资料");
+ }
+ if (plan.getActualEmptyReportedAt() != null) {
+ throw conflict("货柜已还柜,不可再上传DO资料");
+ }
+ if (PriceBookChannelCatalog.isSeaRail(plan.getChannelCode())) {
+ throw conflict("海铁渠道不适用DO资料");
+ }
+ }
@Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
index f34ec5bbe..399d0c584 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java
@@ -92,14 +92,18 @@ public class MeasurementReviewService {
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
var weights = measurements.listWeights(tenantId, inboundId);
- if (weights.isEmpty() || weights.stream().anyMatch(weight ->
+ if (weights.isEmpty()) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "该票测量已提交但没有称重图片;请先退回测量,上传并确认至少一张称重图片后重新提交(即使只有一箱也必须称重)");
+ }
+ if (weights.stream().anyMatch(weight ->
!"CONFIRMED".equals(weight.getRecognitionStatus())
|| weight.getGrossWeightKg() == null
|| weight.getGrossWeightKg().signum() <= 0
|| weight.getNetWeightKg() == null
|| weight.getNetWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
- "所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
+ "该票存在未确认或重量无效的称重图片;请先在审核弹框逐张修正并确认后再审核");
}
BigDecimal grossWeight = weights.stream()
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index a0e04bd50..21375b117 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -1224,7 +1224,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
- description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
+ description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过前必须存在至少一张已确认的称重图片;历史异常数据若已提交但没有称重图片,必须先退回测量后补录。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1245,7 +1245,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
- description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
+ description: 即使只有一箱,也必须先上传并确认至少一张称重图片。提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
index f0c12cb69..35fbed98f 100644
--- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
+++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml
@@ -89,7 +89,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the current saved booking as an export shipping instruction PDF
- description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
+ description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -97,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF
- description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
+ description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml
index 4c4a1797b..73a9812f9 100644
--- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml
+++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml
@@ -39,10 +39,10 @@ paths:
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
post:
operationId: uploadContainerDocument
- description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
+ description: '权限:platform:firstleg:terminal-drayage:document:edit;仅已进入海外履约且未还柜的非海铁渠道可上传DO,无完成动作'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
- responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
+ responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}, '409': {description: '尚未进入海外履约、海铁渠道不适用DO或货柜已还柜'}}
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
get:
operationId: downloadContainerDocument
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java
index 6954b2e87..3b363d893 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java
@@ -27,7 +27,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map submitOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post"));
assertThat(submitOperation.get("description").toString())
- .contains("只校验并复用", "不会首次分配");
+ .contains("即使只有一箱", "上传并确认至少一张称重图片", "只校验并复用", "不会首次分配");
+ Map approveOperation = stringMap(stringMap(paths.get(
+ "/api/platform/v1/inbounds/{inboundId}/measurements/approve")).get("post"));
+ assertThat(approveOperation.get("description").toString())
+ .contains("已确认的称重图片", "历史异常数据", "先退回测量");
Map schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map detailProperties = properties(schemas, "InboundMeasurementDetail");
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
index 25068db02..a27196d45 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java
@@ -31,7 +31,7 @@ class OceanBookingPrintServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); }
- @Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() {
+ @Test void shippingInstructionDoesNotTreatContainerCapacityAsActualCargo() {
when(bookings.detail(10L)).thenReturn(detail("RELEASED"));
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
headers.set("X-Print-Page-Count", "1");
@@ -49,12 +49,17 @@ class OceanBookingPrintServiceTest {
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
+ .containsEntry("cargoQuantity", "—")
+ .containsEntry("totalPackagesInWords", "—")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
assertThat(document.cargoRows()).singleElement().satisfies(row -> {
- assertThat(row.quantity()).isEqualTo("2");
- assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2");
- assertThat(row.grossWeight()).isEqualTo("56000.000 KGS");
+ assertThat(row.marksAndNos()).isEqualTo("—");
+ assertThat(row.quantity()).isEqualTo("—");
+ assertThat(row.packaging()).isEqualTo("—");
+ assertThat(row.descriptionOfGoods()).isEqualTo("—");
+ assertThat(row.grossWeight()).isEqualTo("—");
+ assertThat(row.measurement()).isEqualTo("—");
});
}
@@ -85,7 +90,11 @@ class OceanBookingPrintServiceTest {
.containsEntry("serviceTerm", "—")
.containsEntry("freightTerm", "—");
}
- assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—");
+ assertThat(request.getValue().documents().get(0).fields())
+ .containsEntry("bookingNumber", "—")
+ .containsEntry("containerSpec", "40HQ DRY x 2")
+ .containsEntry("containerSummary", "40HQ DRY x 2")
+ .containsEntry("totalPackagesInWords", "—");
}
private static Detail detail(String status) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
index b4aeb5d9f..dbb5b29cc 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
@@ -1188,7 +1188,8 @@ class InboundMeasurementServiceTest {
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
- .hasMessageContaining("至少上传并确认一张称重图片");
+ .hasMessageContaining("即使只有一箱")
+ .hasMessageContaining("上传并确认至少一张称重图片");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
@@ -1212,7 +1213,8 @@ class InboundMeasurementServiceTest {
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
- .hasMessageContaining("至少上传并确认一张称重图片");
+ .hasMessageContaining("即使只有一箱")
+ .hasMessageContaining("上传并确认至少一张称重图片");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java
index fe004951c..1302c4097 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java
@@ -681,6 +681,48 @@ class LoadingListServiceTest {
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
}
+ @Test
+ void seaRailContainerCannotUploadDeliveryOrder() {
+ AggregateRow plan = row("COMPLETED", 4);
+ plan.setChannelCode("OA_CHICAGO_RAIL");
+ var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
+ when(repository.aggregateByBooking(30L)).thenReturn(plan);
+
+ assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
+ LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-rail", 0, null, file))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("海铁渠道不适用DO资料");
+ verifyNoInteractions(documentFiles);
+ }
+
+ @Test
+ void domesticStageContainerCannotUploadDeliveryOrder() {
+ AggregateRow plan = row("COMPLETED", 4);
+ plan.setOutboundConfirmedAt(null);
+ var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
+ when(repository.aggregateByBooking(30L)).thenReturn(plan);
+
+ assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
+ LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-domestic", 0, null, file))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("进入海外履约后");
+ verifyNoInteractions(documentFiles);
+ }
+
+ @Test
+ void returnedContainerCannotUploadDeliveryOrder() {
+ AggregateRow plan = row("COMPLETED", 4);
+ plan.setActualEmptyReportedAt(java.time.LocalDateTime.now());
+ var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
+ when(repository.aggregateByBooking(30L)).thenReturn(plan);
+
+ assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
+ LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-returned", 0, null, file))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("已还柜");
+ verifyNoInteractions(documentFiles);
+ }
+
@Test
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
AggregateRow plan = row("COMPLETED", 4);
@@ -918,6 +960,8 @@ class LoadingListServiceTest {
row.setContainerBookingId(30L);
row.setPlanStatus(status);
row.setWarehouseId(100L);
+ row.setChannelCode("OA_LA_TRUCK");
+ row.setOutboundConfirmedAt(java.time.LocalDateTime.now());
row.setVersion(version);
return row;
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
index 47d037ebd..291dc1e8f 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java
@@ -135,6 +135,41 @@ class MeasurementReviewServiceTest {
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
+ @Test
+ @DisplayName("历史异常测量已提交但没有称重图片时提示退回补录")
+ void shouldExplainHowToRecoverSubmittedMeasurementWithoutWeightImage() {
+ Fixture fixture = fixture(false);
+ when(measurements.listWeights(10001L, 10L)).thenReturn(List.of());
+
+ assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
+ "approve-without-weight", new MeasurementReviewRequest(20L, 3, null)))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("已提交但没有称重图片")
+ .hasMessageContaining("先退回测量")
+ .hasMessageContaining("即使只有一箱也必须称重");
+
+ verify(fixture.repository, never()).transitionReview(
+ anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
+ }
+
+ @Test
+ @DisplayName("已上传但未确认的称重图片提示在审核弹框修正")
+ void shouldExplainHowToConfirmInvalidWeightImageDuringReview() {
+ Fixture fixture = fixture(false);
+ InboundWeightResultEntity pending = confirmedWeight("357.500", "349.500");
+ pending.setRecognitionStatus("PENDING_CONFIRM");
+ when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(pending));
+
+ assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
+ "approve-pending-weight", new MeasurementReviewRequest(20L, 3, null)))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("未确认或重量无效")
+ .hasMessageContaining("在审核弹框逐张修正并确认");
+
+ verify(fixture.repository, never()).transitionReview(
+ anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
+ }
+
@Test
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
index c59fc9cd9..044f022c2 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
@@ -59,6 +59,14 @@ public class InternalTenantController {
return ApiResponse.success(onboardingService.register(request));
}
+ @GetMapping("/merchant-registrations/{authUserId}")
+ public ApiResponse registration(
+ @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
+ @PathVariable long authUserId) {
+ verify(token);
+ return ApiResponse.success(onboardingService.registration(authUserId));
+ }
+
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
public ApiResponse compensateRegistration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -127,7 +135,7 @@ public class InternalTenantController {
@RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
- @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) {
+ @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) {
verify(token);
return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
index 004b32af5..7378bee76 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
@@ -63,7 +63,7 @@ public class PlatformTenantController {
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
- @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status,
+ @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
index 7d819fa3e..9929e5734 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
@@ -3,9 +3,10 @@ package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
+import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse;
+import com.baiyue.iam.dto.ProfileDtos.QualificationResponse;
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
import com.baiyue.iam.entity.IamTenantAddress;
-import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.PlatformInternalUserService;
import com.baiyue.iam.service.TenantGuard;
@@ -108,21 +109,48 @@ public class ProfileResourceController {
}
@GetMapping("/api/merchant/v1/iam/tenant/qualifications")
+ @PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")})
- public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) {
+ public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT")));
}
+ @GetMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}")
+ @PreAuthorize("hasAuthority('merchant:profile:view')")
+ @Operation(summary = "查询企业资质详情", description = "客户范围来自 JWT,跨客户资质按不存在处理。")
+ public ApiResponse qualification(
+ @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) {
+ return ApiResponse.success(service.qualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId));
+ }
+
+ @DeleteMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}")
+ @PreAuthorize("hasAuthority('merchant:qualification:upload')")
+ @Operation(summary = "删除送审前企业资质", description = "仅待完善或已驳回客户可删除;提交审核后服务端拒绝。重复删除幂等成功。")
+ public ApiResponse deleteQualification(
+ @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) {
+ return ApiResponse.success(service.deleteQualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId));
+ }
+
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。")
- public ApiResponse> platformQualifications(
+ public ApiResponse> platformQualifications(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.platformQualifications(tenantId));
}
+ @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}")
+ @PreAuthorize("hasAuthority('platform:customer:list')")
+ @Operation(summary = "查询企业客户资质详情", description = "需要客户列表权限,且同时校验客户与资质归属。")
+ public ApiResponse platformQualification(
+ @PathVariable long tenantId, @PathVariable long qualificationId,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.tenant(user, "PLATFORM");
+ return ApiResponse.success(service.platformQualification(tenantId, qualificationId));
+ }
+
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。")
diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
index 0383c9105..3b3d5fdb8 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
@@ -3,6 +3,7 @@ package com.baiyue.iam.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
import java.time.LocalDate;
@@ -13,7 +14,7 @@ public final class OnboardingDtos {
@NotNull Long authUserId,
@NotBlank String displayName,
@NotBlank String companyName,
- @NotBlank String legalPerson,
+ @Size(max = 64) String legalPerson,
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone,
String contactEmail,
String countryCode,
@@ -28,6 +29,7 @@ public final class OnboardingDtos {
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
@NotBlank String documentUrl,
+ @NotBlank @Size(max = 255) String originalFileName,
LocalDate validTo,
boolean replace
) { }
@@ -40,6 +42,7 @@ public final class OnboardingDtos {
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
@NotBlank String documentUrl,
+ @NotBlank @Size(max = 255) String originalFileName,
LocalDate validTo,
boolean replace
) { }
@@ -55,9 +58,11 @@ public final class OnboardingDtos {
@NotNull Long tenantId,
@NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode,
@NotBlank String businessLicenseDocumentUrl,
+ @NotBlank @Size(max = 255) String businessLicenseOriginalFileName,
LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
String legalPersonIdDocumentUrl,
+ @Size(max = 255) String legalPersonIdOriginalFileName,
LocalDate legalPersonIdValidTo,
Boolean replaceLegalPersonId
) { }
diff --git a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
index ac9a88edf..626fadd0e 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
@@ -5,6 +5,9 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
/** 用户档案、地址和资质的数据契约。 */
public final class ProfileDtos {
private ProfileDtos() { }
@@ -24,4 +27,12 @@ public final class ProfileDtos {
String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { }
+ public record QualificationResponse(Long id, Long tenantId, String qualificationType,
+ String certificateNo, String documentUrl, String originalFileName,
+ LocalDate validFrom, LocalDate validTo, String status,
+ String reviewComment, LocalDateTime createdAt,
+ LocalDateTime updatedAt) { }
+
+ public record QualificationDeletionResponse(Long qualificationId, String deletedDocumentUrl) { }
+
}
diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
index aca36a421..ae59df536 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
@@ -25,7 +25,7 @@ public final class TenantDtos {
public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
- @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
+ @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone,
@@ -46,7 +46,7 @@ public final class TenantDtos {
public record UpdateProfileRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
- @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
+ @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
@Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@@ -58,7 +58,7 @@ public final class TenantDtos {
public record UpdateTenantRequest(
@NotBlank String tenantName,
@NotBlank String legalName,
- @NotBlank @Size(max = 64) String legalRepresentative,
+ @Size(max = 64) String legalRepresentative,
@NotBlank String contactName,
@Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java
index eb5585fdb..af1a2bd5f 100644
--- a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java
+++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java
@@ -2,10 +2,10 @@ package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
-import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate;
+import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; import java.time.LocalDateTime;
/**
* 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。
*/
@Getter
@Setter
-@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; }
+@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private String originalFileName; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; private LocalDateTime createdAt; private LocalDateTime updatedAt; @TableLogic private Boolean deleted; }
diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java
index 5fc245647..cf80bc93d 100644
--- a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java
+++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java
@@ -31,6 +31,18 @@ public class ProfileResourceRepository {
public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); }
public List findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper().eq(IamTenantAddress::getTenantId, tenantId)); }
public List findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper().eq(IamTenantQualification::getTenantId, tenantId)); }
+ public IamTenantQualification findQualification(long tenantId, long qualificationId) {
+ return qualificationMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamTenantQualification::getTenantId, tenantId)
+ .eq(IamTenantQualification::getId, qualificationId));
+ }
+ public IamTenantQualification findQualificationForUpdate(long tenantId, long qualificationId) {
+ return qualificationMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamTenantQualification::getTenantId, tenantId)
+ .eq(IamTenantQualification::getId, qualificationId)
+ .last("FOR UPDATE"));
+ }
+ public void deleteQualification(IamTenantQualification qualification) { qualificationMapper.deleteById(qualification); }
public List findQualificationsForUpdate(long tenantId) {
return qualificationMapper.selectList(new LambdaQueryWrapper()
.eq(IamTenantQualification::getTenantId, tenantId)
diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
index c2fd87bae..ca8c1b9c4 100644
--- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
+++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
@@ -83,6 +83,7 @@ public class TenantRepository {
public PageResult pageMerchants(long page, long size, String keyword, String status) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(IamTenant::getTenantType, "MERCHANT")
+ .ne(IamTenant::getStatus, "ARCHIVED")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByDesc(IamTenant::getCreatedAt);
if (StringUtils.hasText(keyword)) {
@@ -114,6 +115,7 @@ public class TenantRepository {
public List exportMerchants(Long cursor, int limit, String keyword, String status) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.eq(IamTenant::getTenantType, "MERCHANT")
+ .ne(IamTenant::getStatus, "ARCHIVED")
.gt(cursor != null, IamTenant::getId, cursor)
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByAsc(IamTenant::getId)
diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java
index 7073f3fd9..5dd4d22fa 100644
--- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java
@@ -74,7 +74,7 @@ public class MerchantOnboardingService {
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.companyName());
- tenant.setLegalRepresentative(request.legalPerson());
+ tenant.setLegalRepresentative(normalizeOptional(request.legalPerson()));
tenant.setContactName(request.displayName());
tenant.setContactPhone(request.administratorPhone());
tenant.setContactEmail(request.contactEmail());
@@ -133,6 +133,32 @@ public class MerchantOnboardingService {
tenant.getDimensionInitializationStatus());
}
+ public MerchantRegistrationResponse registration(long authUserId) {
+ IamUser user = userMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamUser::getAuthUserId, authUserId)
+ .last("LIMIT 1"));
+ if (user == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
+ }
+ IamMembership membership = membershipMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamMembership::getUserId, user.getId())
+ .eq(IamMembership::getMemberType, "OWNER")
+ .last("LIMIT 1"));
+ if (membership == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
+ }
+ IamTenant tenant = tenantMapper.selectById(membership.getTenantId());
+ if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
+ throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
+ }
+ return new MerchantRegistrationResponse(tenant.getId(), membership.getId(), tenant.getStatus(),
+ tenant.getDimensionInitializationStatus());
+ }
+
+ private String normalizeOptional(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
+
/**
* Removes a just-created IAM registration when Auth could not persist the paired login account.
* Repeated calls are safe, while progressed onboarding data is deliberately protected.
diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
index af90e9bf5..4b1cc284a 100644
--- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
@@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
+import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse;
+import com.baiyue.iam.dto.ProfileDtos.QualificationResponse;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
@@ -43,16 +45,50 @@ public class ProfileResourceService {
repository.insertAddress(a); return a;
}
- public List qualifications(long tenant) { return repository.findQualifications(tenant); }
+ public List qualifications(long tenant) {
+ return repository.findQualifications(tenant).stream().map(this::toQualificationResponse).toList();
+ }
+
+ public QualificationResponse qualification(long tenant, long qualificationId) {
+ IamTenantQualification qualification = repository.findQualification(tenant, qualificationId);
+ if (qualification == null) throw new BizException(ResultCode.NOT_FOUND, "Qualification not found / 企业资质不存在");
+ return toQualificationResponse(qualification);
+ }
public List platformAddresses(long tenantId) {
requireMerchant(tenantId);
return repository.findAddresses(tenantId);
}
- public List platformQualifications(long tenantId) {
+ public List platformQualifications(long tenantId) {
requireMerchant(tenantId);
- return repository.findQualifications(tenantId);
+ return qualifications(tenantId);
+ }
+
+ public QualificationResponse platformQualification(long tenantId, long qualificationId) {
+ requireMerchant(tenantId);
+ return qualification(tenantId, qualificationId);
+ }
+
+ @Transactional
+ @BusinessAudit(event = "TENANT_QUALIFICATION_DELETE", resourceType = "TENANT_QUALIFICATION",
+ resourceId = "#qualificationId", description = "删除送审前企业资质", captureAfter = false)
+ public QualificationDeletionResponse deleteQualification(long tenantId, long qualificationId) {
+ IamTenant tenant = tenantRepository.findTenantForUpdate(tenantId);
+ if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
+ throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
+ }
+ if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
+ throw new BizException(ResultCode.CONFLICT,
+ "Qualifications cannot be deleted after review submission / 资质提交审核后不可删除");
+ }
+ IamTenantQualification qualification = repository.findQualificationForUpdate(tenantId, qualificationId);
+ if (qualification == null) {
+ return new QualificationDeletionResponse(qualificationId, null);
+ }
+ String documentUrl = qualification.getDocumentUrl();
+ repository.deleteQualification(qualification);
+ return new QualificationDeletionResponse(qualificationId, documentUrl);
}
@Transactional
@@ -86,7 +122,7 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(),
- request.qualificationType(), certificateNo, request.documentUrl());
+ request.qualificationType(), certificateNo, request.documentUrl(), request.originalFileName());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
@@ -122,7 +158,7 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
- certificateNo, request.documentUrl());
+ certificateNo, request.documentUrl(), request.originalFileName());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
return new PlatformQualificationRegistrationResponse(
@@ -162,6 +198,7 @@ public class ProfileResourceService {
requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照");
boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl());
if (hasLegalPersonId) {
+ normalizeOriginalFileName(request.legalPersonIdOriginalFileName());
requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证");
} else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) {
throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换");
@@ -170,13 +207,13 @@ public class ProfileResourceService {
String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment);
String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null;
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
- creditCode, request.businessLicenseDocumentUrl());
+ creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName());
businessLicense.setValidTo(request.businessLicenseValidTo());
repository.saveQualification(businessLicense);
IamTenantQualification legalPersonId = null;
if (hasLegalPersonId) {
legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
- null, request.legalPersonIdDocumentUrl().trim());
+ null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName());
legalPersonId.setValidTo(request.legalPersonIdValidTo());
repository.saveQualification(legalPersonId);
}
@@ -243,13 +280,32 @@ public class ProfileResourceService {
return certificateNo;
}
- private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) {
+ private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url,
+ String originalFileName) {
IamTenantQualification x = new IamTenantQualification();
x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type);
- x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING");
+ x.setCertificateNo(certificateNo); x.setDocumentUrl(url);
+ x.setOriginalFileName(normalizeOriginalFileName(originalFileName)); x.setStatus("PENDING");
repository.insertQualification(x); return x;
}
+ private String normalizeOriginalFileName(String originalFileName) {
+ if (!StringUtils.hasText(originalFileName)) {
+ throw new BizException(ResultCode.BAD_REQUEST, "Original file name is required / 必须提供上传时原始文件名");
+ }
+ String normalized = originalFileName.trim();
+ if (normalized.length() > 255) {
+ throw new BizException(ResultCode.BAD_REQUEST, "Original file name is too long / 原始文件名不能超过255个字符");
+ }
+ return normalized;
+ }
+
+ private QualificationResponse toQualificationResponse(IamTenantQualification item) {
+ return new QualificationResponse(item.getId(), item.getTenantId(), item.getQualificationType(),
+ item.getCertificateNo(), item.getDocumentUrl(), item.getOriginalFileName(), item.getValidFrom(),
+ item.getValidTo(), item.getStatus(), item.getReviewComment(), item.getCreatedAt(), item.getUpdatedAt());
+ }
+
private boolean isCurrentQualification(IamTenantQualification qualification) {
return !"REJECTED".equals(qualification.getStatus())
&& !"SUPERSEDED".equals(qualification.getStatus());
diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
index b46d9f380..5659ec3de 100644
--- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
@@ -174,7 +174,7 @@ public class TenantService {
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.legalName());
- tenant.setLegalRepresentative(request.legalRepresentative());
+ tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setUnifiedSocialCreditCode(null);
tenant.setContactName(request.contactName());
tenant.setContactPhone(contactPhone);
@@ -391,7 +391,7 @@ public class TenantService {
String contactPhone = normalizeContactPhone(request.contactPhone());
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
- tenant.setLegalRepresentative(request.legalRepresentative());
+ tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName());
tenant.setContactPhone(contactPhone);
tenant.setContactEmail(request.contactEmail());
@@ -463,7 +463,7 @@ public class TenantService {
ensureNotArchived(tenant);
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
- tenant.setLegalRepresentative(request.legalRepresentative());
+ tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName());
tenant.setContactPhone(normalizeContactPhone(request.contactPhone()));
tenant.setContactEmail(request.contactEmail());
@@ -485,6 +485,10 @@ public class TenantService {
return contactPhone == null ? "" : contactPhone.trim();
}
+ private String normalizeOptional(String value) {
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+
private void ensureAdministratorPhoneAvailable(String phone) {
if (!administratorPhoneAvailable(phone)) {
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql
index 1916a76b4..0d7e51849 100644
--- a/iam/src/main/resources/db/iam.sql
+++ b/iam/src/main/resources/db/iam.sql
@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
+ original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml
index 8abc9a16e..f5c939631 100644
--- a/iam/src/main/resources/openapi/iam-v1.yaml
+++ b/iam/src/main/resources/openapi/iam-v1.yaml
@@ -95,7 +95,7 @@ paths:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索}
- - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}}
+ - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses:
'200':
description: 企业客户分页查询成功
@@ -248,6 +248,22 @@ paths:
schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
+ /api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:
+ parameters:
+ - {$ref: '#/components/parameters/TenantId'}
+ - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}}
+ get:
+ tags: [Platform Tenant / 平台租户]
+ summary: 查询企业客户资质详情
+ description: 需要 `platform:customer:list` 权限,并同时校验客户与资质归属。
+ responses:
+ '200':
+ description: 企业客户资质详情查询成功
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/status:
parameters:
- {$ref: '#/components/parameters/TenantId'}
@@ -677,8 +693,39 @@ paths:
tags: [IAM Resources / IAM 资源]
summary: List tenant qualifications / 查询企业资质
responses:
- '200': {$ref: '#/components/responses/Success'}
+ '200':
+ description: 当前客户资质列表
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
+ /api/merchant/v1/iam/tenant/qualifications/{qualificationId}:
+ parameters:
+ - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}}
+ get:
+ tags: [IAM Resources / IAM 资源]
+ summary: 查询当前客户资质详情
+ description: 需要 `merchant:profile:view` 权限;客户范围来自 JWT,跨客户资质按不存在处理。
+ responses:
+ '200':
+ description: 当前客户资质详情
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
+ delete:
+ tags: [IAM Resources / IAM 资源]
+ summary: 删除送审前企业资质
+ description: 需要 `merchant:qualification:upload` 权限;仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许,`PENDING`、`ACTIVE` 等提交审核后的状态由服务端拒绝。客户范围来自 JWT;重复删除或跨客户 ID 返回相同幂等结果,不泄露归属。
+ responses:
+ '200':
+ description: 删除成功或目标已不存在
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/QualificationDeletionApiResponse'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/tenant/qualifications/submit-review:
post:
tags: [IAM Resources / IAM 资源]
@@ -810,6 +857,69 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
+ /internal/iam/merchant-registrations/{authUserId}:
+ get:
+ tags: [IAM Resources / IAM 资源]
+ summary: 查询已创建的企业注册状态
+ description: 仅供 Auth Service 对相同账号、手机号和密码的注册重试进行幂等确认,不创建或修改企业资料。
+ security: []
+ parameters:
+ - {$ref: '#/components/parameters/InternalServiceToken'}
+ - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64}}
+ responses:
+ '200':
+ description: 注册状态查询成功
+ content:
+ application/json:
+ schema:
+ allOf:
+ - {$ref: '#/components/schemas/ApiResponse'}
+ - type: object
+ properties:
+ data:
+ type: object
+ required: [tenantId, membershipId, tenantStatus, dimensionInitializationStatus]
+ properties:
+ tenantId: {type: integer, format: int64}
+ membershipId: {type: integer, format: int64}
+ tenantStatus: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
+ dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED]}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
+ /internal/iam/qualifications:
+ post:
+ tags: [IAM Resources / IAM 资源]
+ summary: 登记客户单个选填资质
+ description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。
+ security: []
+ parameters:
+ - {$ref: '#/components/parameters/InternalServiceToken'}
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
+ /internal/iam/platform-qualifications:
+ post:
+ tags: [IAM Resources / IAM 资源]
+ summary: 平台代客户登记单个选填资质
+ description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。
+ security: []
+ parameters:
+ - {$ref: '#/components/parameters/InternalServiceToken'}
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
/internal/iam/required-qualification-packages:
post:
tags: [IAM Resources / IAM 资源]
@@ -1204,7 +1314,7 @@ components:
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
legalName: {type: string}
- legalRepresentative: {type: string, maxLength: 64}
+ legalRepresentative: {type: string, maxLength: 64, nullable: true}
unifiedSocialCreditCode: {type: string}
contactName: {type: string}
contactPhone: {type: string}
@@ -1258,6 +1368,23 @@ components:
data:
type: array
items: {$ref: '#/components/schemas/TenantQualification'}
+ TenantQualificationApiResponse:
+ allOf:
+ - {$ref: '#/components/schemas/ApiResponse'}
+ - type: object
+ properties:
+ data: {$ref: '#/components/schemas/TenantQualification'}
+ QualificationDeletionApiResponse:
+ allOf:
+ - {$ref: '#/components/schemas/ApiResponse'}
+ - type: object
+ properties:
+ data:
+ type: object
+ required: [qualificationId]
+ properties:
+ qualificationId: {type: string, pattern: '^\d+$'}
+ deletedDocumentUrl: {type: string, nullable: true, description: 首次删除时返回,供 File Service 回收文件;重复删除为空}
TenantAddress:
type: object
properties:
@@ -1273,26 +1400,43 @@ components:
isDefault: {type: boolean}
TenantQualification:
type: object
+ required: [id, tenantId, qualificationType, documentUrl, originalFileName, status, createdAt, updatedAt]
properties:
id: {type: string, pattern: '^\d+$'}
tenantId: {type: string, pattern: '^\d+$'}
qualificationType: {type: string, enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]}
certificateNo: {type: string}
documentUrl: {type: string}
+ originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}
validFrom: {type: string, format: date}
validTo: {type: string, format: date, nullable: true}
status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
reviewComment: {type: string}
+ createdAt: {type: string, format: date-time}
+ updatedAt: {type: string, format: date-time}
+ QualificationSubmissionRequest:
+ type: object
+ required: [tenantId, qualificationType, documentUrl, originalFileName, replace]
+ properties:
+ tenantId: {type: string, pattern: '^\d+$'}
+ qualificationType: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]}
+ certificateNo: {type: string, nullable: true}
+ documentUrl: {type: string, minLength: 1}
+ originalFileName: {type: string, minLength: 1, maxLength: 255}
+ validTo: {type: string, format: date, nullable: true}
+ replace: {type: boolean}
RequiredQualificationPackageRequest:
type: object
- required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense]
+ required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, businessLicenseOriginalFileName, replaceBusinessLicense]
properties:
tenantId: {type: string, pattern: '^\d+$'}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'}
businessLicenseDocumentUrl: {type: string, minLength: 1}
+ businessLicenseOriginalFileName: {type: string, minLength: 1, maxLength: 255}
businessLicenseValidTo: {type: string, format: date, nullable: true}
replaceBusinessLicense: {type: boolean}
legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填}
+ legalPersonIdOriginalFileName: {type: string, maxLength: 255, nullable: true, description: 上传法人身份证时必传}
legalPersonIdValidTo: {type: string, format: date, nullable: true}
replaceLegalPersonId: {type: boolean, nullable: true, default: false}
RequiredQualificationPackageResponse:
@@ -1312,11 +1456,11 @@ components:
data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'}
RegisterRequest:
type: object
- required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage]
+ required: [tenantName, legalName, contactName, contactPhone, servicePackage]
properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
- legalRepresentative: {type: string, maxLength: 64, example: 张三}
+ legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填}
contactName: {type: string, example: 李四}
contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护}
contactEmail: {type: string, format: email, example: contact@example.com}
@@ -1332,21 +1476,21 @@ components:
internalRemark: {type: string, maxLength: 1000}
UpdateProfileRequest:
type: object
- required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
+ required: [tenantName, legalName, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
- legalRepresentative: {type: string, maxLength: 64}
+ legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string}
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一}
contactEmail: {type: string, format: email}
UpdateTenantRequest:
type: object
- required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
+ required: [tenantName, legalName, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
- legalRepresentative: {type: string, maxLength: 64}
+ legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string}
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一}
contactEmail: {type: string, format: email}
diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
index 12d675ba8..9223ad914 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
@@ -76,6 +76,12 @@ class IamOpenApiContractTest {
.contains("id: {type: string, pattern: '^\\d+$'")
.contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:")
.contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:")
+ .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:")
+ .contains("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}:")
+ .contains("originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}")
+ .contains("updatedAt: {type: string, format: date-time}")
+ .contains("仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许")
+ .contains("merchant:qualification:upload")
.contains("platform:customer:list")
.doesNotContain("platform:customer:view")
.contains("TenantAddressListApiResponse:")
diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java
index fdcb270cb..2c05b5d40 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java
@@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
import java.lang.reflect.Method;
@@ -17,6 +18,22 @@ class PlatformCustomerResourceContractTest {
"/api/platform/v1/iam/tenants/{tenantId}/addresses");
assertListPermissionContract("platformQualifications",
"/api/platform/v1/iam/tenants/{tenantId}/qualifications");
+ Method platformDetail = ProfileResourceController.class.getMethod(
+ "platformQualification", long.class, long.class, AuthenticatedUser.class);
+ assertEquals("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}",
+ platformDetail.getAnnotation(GetMapping.class).value()[0]);
+ assertEquals("hasAuthority('platform:customer:list')",
+ platformDetail.getAnnotation(PreAuthorize.class).value());
+ Method merchantDetail = ProfileResourceController.class.getMethod(
+ "qualification", long.class, AuthenticatedUser.class);
+ assertEquals("hasAuthority('merchant:profile:view')",
+ merchantDetail.getAnnotation(PreAuthorize.class).value());
+ Method merchantDelete = ProfileResourceController.class.getMethod(
+ "deleteQualification", long.class, AuthenticatedUser.class);
+ assertEquals("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}",
+ merchantDelete.getAnnotation(DeleteMapping.class).value()[0]);
+ assertEquals("hasAuthority('merchant:qualification:upload')",
+ merchantDelete.getAnnotation(PreAuthorize.class).value());
}
private void assertListPermissionContract(String methodName, String path) throws Exception {
diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
index a6fc8ce28..b2ed7fb78 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
assertNotNull(annotation(method, 0, Min.class));
assertNotNull(annotation(method, 1, Min.class));
assertEquals(200, annotation(method, 1, Max.class).value());
- assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
+ assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
annotation(method, 3, Pattern.class).regexp());
Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
diff --git a/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java
new file mode 100644
index 000000000..b33acb2ac
--- /dev/null
+++ b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java
@@ -0,0 +1,19 @@
+package com.baiyue.iam.repository;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ArchivedCustomerListContractTest {
+ @Test
+ void customerListAndExportExcludeArchivedTenants() throws Exception {
+ String source = Files.readString(Path.of(
+ "src/main/java/com/baiyue/iam/repository/TenantRepository.java"));
+
+ assertThat(source.split("\\.ne\\(IamTenant::getStatus, \\\"ARCHIVED\\\"\\)", -1))
+ .hasSizeGreaterThanOrEqualTo(3);
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
new file mode 100644
index 000000000..fff5b2223
--- /dev/null
+++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
@@ -0,0 +1,26 @@
+package com.baiyue.iam.repository;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class QualificationSchemaContractTest {
+ @Test
+ void baselineAndUnifiedSqlPersistTheUploadOriginalFileName() throws Exception {
+ String baseline = new ClassPathResource("db/iam.sql")
+ .getContentAsString(StandardCharsets.UTF_8);
+ Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
+ if (!Files.exists(unifiedPath)) {
+ unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
+ }
+ String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
+
+ assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
+ assertThat(unified).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java
index 7e4501eb1..339dc050c 100644
--- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java
@@ -25,6 +25,7 @@ import jakarta.annotation.Resource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -103,6 +104,52 @@ class MerchantOnboardingServiceTest {
assertNotNull(result.tenantId());
}
+ @Test
+ void clientRegistrationAcceptsAnOmittedLegalRepresentative() {
+ IamRole ownerRole = new IamRole();
+ ownerRole.setId(4000100L);
+ ownerRole.setTenantId(0L);
+ ownerRole.setAppCode("MERCHANT");
+ ownerRole.setRoleCode("MERCHANT_OWNER");
+ ownerRole.setStatus("ACTIVE");
+ when(roles.selectOne(any())).thenReturn(ownerRole);
+
+ service.register(new MerchantRegistrationRequest(
+ 9001L, "注册人", "测试企业", null, "13800138000",
+ "owner@example.com", "CN", "深圳市宝安区"));
+
+ ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class);
+ verify(tenants).insert(tenant.capture());
+ assertNull(tenant.getValue().getLegalRepresentative());
+ }
+
+ @Test
+ void registrationLookupReturnsTheExistingQualificationDraftState() {
+ IamUser user = new IamUser();
+ user.setId(20001L);
+ user.setAuthUserId(9001L);
+ IamMembership membership = new IamMembership();
+ membership.setId(30001L);
+ membership.setTenantId(10001L);
+ membership.setUserId(20001L);
+ membership.setMemberType("OWNER");
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L);
+ tenant.setTenantType("MERCHANT");
+ tenant.setStatus("PROFILE_REQUIRED");
+ tenant.setDimensionInitializationStatus("READY");
+ when(users.selectOne(any())).thenReturn(user);
+ when(memberships.selectOne(any())).thenReturn(membership);
+ when(tenants.selectById(10001L)).thenReturn(tenant);
+
+ var result = service.registration(9001L);
+
+ assertEquals(10001L, result.tenantId());
+ assertEquals(30001L, result.membershipId());
+ assertEquals("PROFILE_REQUIRED", result.tenantStatus());
+ assertEquals("READY", result.dimensionInitializationStatus());
+ }
+
@Test
void compensationRemovesOnlyAnUnstartedRegistration() {
diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
index 80173ee17..ef40a7b22 100644
--- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
@@ -15,6 +15,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -43,7 +44,8 @@ class MerchantQualificationUploadTest {
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "FREIGHT_FORWARDER_CERTIFICATE", null,
- "/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false));
+ "/api/merchant/v1/files/qualifications/freight-forwarder.png", "freight-forwarder.png",
+ LocalDate.of(2030, 1, 1), false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId());
@@ -68,6 +70,7 @@ class MerchantQualificationUploadTest {
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "LEGAL_PERSON_ID", null,
"/api/merchant/v1/files/qualifications/new-legal-person.png",
+ "new-legal-person.png",
LocalDate.of(2035, 12, 31), true));
assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png",
@@ -92,7 +95,7 @@ class MerchantQualificationUploadTest {
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest(
10001L, "BUSINESS_LICENSE", "91310101abcde12345",
- "/api/merchant/v1/files/qualifications/license.png", null, false)));
+ "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false)));
verify(profileRepository, never()).insertQualification(any());
}
@@ -108,8 +111,8 @@ class MerchantQualificationUploadTest {
var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "91310101abcde12345",
- "/api/merchant/v1/files/qualifications/license.png", null, false,
- "/api/merchant/v1/files/qualifications/legal-person.png", null, false));
+ "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false,
+ "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode());
@@ -128,8 +131,8 @@ class MerchantQualificationUploadTest {
var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "91310101abcde12345",
- "/api/merchant/v1/files/qualifications/license.png", null, false,
- null, null, null));
+ "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false,
+ null, null, null, null));
assertNull(response.legalPersonIdQualificationId());
verify(profileRepository, times(1)).insertQualification(any());
@@ -147,12 +150,66 @@ class MerchantQualificationUploadTest {
assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage(
new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345",
- "/api/merchant/v1/files/qualifications/license.png", null, true,
- "/api/merchant/v1/files/qualifications/legal-person.png", null, true)));
+ "/api/merchant/v1/files/qualifications/license.png", "license.png", null, true,
+ "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, true)));
verify(profileRepository, never()).insertQualification(any());
}
+ @Test
+ void qualificationListReturnsPersistedOriginalFileNameAndUpdatedTime() {
+ IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1");
+ item.setOriginalFileName("法人身份证.png");
+ item.setUpdatedAt(LocalDateTime.of(2026, 9, 11, 10, 30));
+ when(profileRepository.findQualifications(10001L)).thenReturn(List.of(item));
+
+ var response = service.qualifications(10001L);
+
+ assertEquals("法人身份证.png", response.get(0).originalFileName());
+ assertEquals(LocalDateTime.of(2026, 9, 11, 10, 30), response.get(0).updatedAt());
+ }
+
+ @Test
+ void deletesDraftQualificationWithTenantLock() {
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
+ IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1");
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
+ when(profileRepository.findQualificationForUpdate(10001L, 1L)).thenReturn(item);
+
+ var response = service.deleteQualification(10001L, 1L);
+
+ assertEquals(1L, response.qualificationId());
+ assertEquals("/files/qualification/1", response.deletedDocumentUrl());
+ verify(profileRepository).deleteQualification(item);
+ }
+
+ @Test
+ void repeatedOrCrossTenantDeleteIsIdempotentAndDoesNotLeakOwnership() {
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("REJECTED");
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
+ when(profileRepository.findQualificationForUpdate(10001L, 99L)).thenReturn(null);
+
+ var response = service.deleteQualification(10001L, 99L);
+
+ assertEquals(99L, response.qualificationId());
+ assertNull(response.deletedDocumentUrl());
+ verify(profileRepository, never()).deleteQualification(any());
+ }
+
+ @Test
+ void rejectsDeleteAfterReviewSubmission() {
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PENDING");
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
+
+ assertThrows(BizException.class, () -> service.deleteQualification(10001L, 1L));
+
+ verify(profileRepository, never()).findQualificationForUpdate(10001L, 1L);
+ verify(profileRepository, never()).deleteQualification(any());
+ }
+
private static IamTenantQualification qualification(String type, String documentUrl) {
IamTenantQualification qualification = new IamTenantQualification();
qualification.setId(1L);
diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
index b6131881b..38c2d7ec3 100644
--- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
@@ -35,7 +35,7 @@ class PlatformQualificationRegistrationTest {
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf",
- LocalDate.of(2030, 12, 31), false));
+ "file.pdf", LocalDate.of(2030, 12, 31), false));
ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository).insertQualification(captor.capture());
@@ -60,7 +60,7 @@ class PlatformQualificationRegistrationTest {
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
- LocalDate.of(2031, 12, 31), true));
+ "new-file.pdf", LocalDate.of(2031, 12, 31), true));
ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository, times(2)).saveQualification(captor.capture());
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java
index 88f84bda9..4fe040594 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java
@@ -29,7 +29,8 @@ public record LastlegOrderSummary(
String transferWarehouseName,
String fbaWarehouseCode,
String containerNo,
- String containerStatus,
+ @Schema(description = "由柜级船节点门禁读取的最新标准货柜节点")
+ String containerNode,
Boolean abnormal,
Boolean detained,
Boolean heldChargeable,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java
index b86b2a53f..574def390 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java
@@ -36,7 +36,6 @@ public class LastlegOrder {
private String transferWarehouseId;
private String transferWarehouseCode;
private String transferWarehouseName;
- private String containerStatus;
private Boolean abnormal;
private Boolean detained;
private Boolean holdChargeable;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
index bde438fbd..0e0714e01 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java
@@ -31,6 +31,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
+import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
+import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -74,12 +76,16 @@ public class LastlegOrderService {
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
+ @Resource private ContainerShippingGateMapper containerShippingGates;
public PageResult page(String customerId, LastlegOrderRequests.Query query) {
Page page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt));
Map> mergeRelations = palletMergeRelations(page.getRecords());
- return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(),
+ Map containerNodes = containerNodes(page.getRecords());
+ return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
+ StringUtils.hasText(order.getSourceContainerBookingId())
+ ? containerNodes.get(order.getSourceContainerBookingId()) : null)).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -261,7 +267,8 @@ public class LastlegOrderService {
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
value.getNodeCode(), value.getNodeAt(), value.getSource()))
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
- return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
+ return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()),
+ containerNode(order)), manualNodes, cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
@@ -480,20 +487,22 @@ public class LastlegOrderService {
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
- output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
+ output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
+ Map containerNodes = containerNodes(orders);
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(displayInboundCode(order)),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
- csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
+ csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
+ ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
@@ -558,13 +567,14 @@ public class LastlegOrderService {
return result;
}
- private LastlegOrderSummary summary(LastlegOrder o, List relations) {
+ private LastlegOrderSummary summary(LastlegOrder o, List relations,
+ String containerNode) {
List related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
- o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
+ o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -576,6 +586,25 @@ public class LastlegOrderService {
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
+ private String containerNode(LastlegOrder order) {
+ if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null;
+ ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId());
+ return gate == null ? null : gate.getStandardNode();
+ }
+
+ private Map containerNodes(List orders) {
+ List bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId)
+ .filter(StringUtils::hasText).distinct().toList();
+ if (bookingIds.isEmpty()) return Map.of();
+ Map result = new LinkedHashMap<>();
+ containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> {
+ if (StringUtils.hasText(gate.getStandardNode())) {
+ result.put(gate.getContainerBookingId(), gate.getStandardNode());
+ }
+ });
+ return result;
+ }
+
private static String displayInboundCode(LastlegOrder order) {
String orderType = order.getOrderType();
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
index 1d8a01fa1..80b5175d1 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
@@ -253,7 +253,7 @@ public class LoadingCompletedConsumer {
order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null);
order.setContainerNo(containerNo);
- order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
+ order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index 21ec376d0..3718bfa44 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -67,7 +67,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码',
container_no VARCHAR(64) COMMENT '集装箱号',
- container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
@@ -444,7 +443,6 @@ ALTER TABLE ll_order
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
- MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
index f720cfcd9..d7e620312 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java
@@ -18,6 +18,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
+import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
+import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -64,6 +66,7 @@ class LastlegOrderServiceTest {
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
+ @Mock ContainerShippingGateMapper containerShippingGates;
LastlegOrderService service;
@BeforeEach
@@ -85,6 +88,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
+ ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
}
@Test
@@ -158,6 +162,26 @@ class LastlegOrderServiceTest {
.noneMatch(component -> "eventId".equals(component.getName())));
}
+ @Test
+ void detailReadsContainerNodeFromTheContainerShippingGate() {
+ LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
+ order.setSourceContainerBookingId("BOOKING-1");
+ ContainerShippingGate gate = new ContainerShippingGate();
+ gate.setContainerBookingId("BOOKING-1");
+ gate.setStandardNode("PICKUP_READY");
+ when(orders.selectOne(any())).thenReturn(order);
+ when(details.selectList(any())).thenReturn(List.of());
+ when(histories.selectList(any())).thenReturn(List.of());
+ when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of());
+ when(operations.selectList(any())).thenReturn(List.of());
+ when(manualNodes.selectList(any())).thenReturn(List.of());
+ when(containerShippingGates.selectById("BOOKING-1")).thenReturn(gate);
+
+ var response = service.detail("100", 11L);
+
+ assertEquals("PICKUP_READY", response.order().containerNode());
+ }
+
@Test
void transferUpdatesOrderAndEveryCargoSnapshot() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
index d185bd58f..48261eaa9 100644
--- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
+++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
@@ -117,4 +117,5 @@ public class InternalPdfController {
.header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
}
+
}
diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs
index 4344c1e76..f85a82438 100644
--- a/scripts/admin-test-env-acceptance.mjs
+++ b/scripts/admin-test-env-acceptance.mjs
@@ -295,6 +295,7 @@ function usage() {
--wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换
--wait-split-sequence 单票全局序号,正整数,默认1
--wait-split-random-value 单票随机选择值,正整数,默认使用全局序号
+ --prepare-dropship-manual-count 一件代发停在PDA清点前,不领取、不清点、不归托
--verify-pallet-location-guide 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
--route-mode 线路选择:fixed 或 random,默认fixed
--manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用
@@ -364,6 +365,7 @@ export function parseArguments(argv) {
waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0),
waitSplitSequence: Number(values.get('wait-split-sequence') || 1),
waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1),
+ prepareDropshipManualCount: values.get('prepare-dropship-manual-count') === 'true',
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
routeMode: values.get('route-mode') || 'fixed',
quiet: values.get('quiet') === 'true',
@@ -423,6 +425,10 @@ export function validateExecutionOptions(options) {
&& (!options.originWarehouseName || !options.channelName || !options.channelCode)) {
throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码')
}
+ if (options.prepareDropshipManualCount
+ && (!options.modules.includes('orders') || !options.modules.includes('inbound-measurement'))) {
+ throw new Error('prepare-dropship-manual-count要求同时执行orders和inbound-measurement模块')
+ }
if (options.waitSplitCount > 1) {
const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation'])
diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs
index d088c1f97..74ac017e7 100644
--- a/scripts/admin-test-env-modules/inbound-measurement.mjs
+++ b/scripts/admin-test-env-modules/inbound-measurement.mjs
@@ -128,17 +128,24 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`)
const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse,
`boxCode=${boxCode}`, OPTIONS)
- context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU数完整`, resolveResponse,
- resolved?.canCount === true && Number(resolved?.expectedSkuQuantity) > 0,
- `canCount=${resolved?.canCount ?? 'missing'}, expectedSkuQuantity=${resolved?.expectedSkuQuantity ?? 'missing'}`, OPTIONS)
- if (resolved?.canCount !== true || Number(resolved?.expectedSkuQuantity) < 1) continue
+ const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : []
+ context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse,
+ resolved?.canCount === true && resolvedSkuLines.length > 0
+ && resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode
+ && Number(line?.expectedQuantity) > 0),
+ `canCount=${resolved?.canCount ?? 'missing'}, skuLines=${resolvedSkuLines.length}`, OPTIONS)
+ if (resolved?.canCount !== true || resolvedSkuLines.length < 1) continue
const countPayload = {
warehouseId,
boxCode,
- actualSkuQuantity: Number(resolved.expectedSkuQuantity),
- discrepancyAction: 'MATCH',
- discrepancyRemark: null,
+ skuLines: resolvedSkuLines.map((line) => ({
+ inboundDetailId: asId(line.inboundDetailId),
+ skuCode: line.skuCode,
+ actualQuantity: Number(line.expectedQuantity),
+ discrepancyAction: 'MATCH',
+ discrepancyRemark: null,
+ })),
palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo,
operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128),
}
@@ -148,10 +155,11 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
`boxCode=${boxCode}`, OPTIONS)
context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse,
counted?.carton?.boxCode === resolved?.boxCode
- && Number(counted?.carton?.expectedSkuQuantity) === Number(resolved?.expectedSkuQuantity)
- && Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity)
- && counted?.carton?.discrepancyStatus === 'MATCH',
- `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS)
+ && Array.isArray(counted?.carton?.skuLines)
+ && counted.carton.skuLines.length === resolvedSkuLines.length
+ && counted.carton.skuLines.every((line) => Number(line?.actualQuantity) === Number(line?.expectedQuantity)
+ && line?.discrepancyStatus === 'MATCH'),
+ `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, skuLines=${counted?.carton?.skuLines?.length ?? 'missing'}`, OPTIONS)
if (counted?.carton?.boxCode === resolved?.boxCode) {
countedCartons += 1
sealedCartonMarks.push(resolved.boxCode)
@@ -1035,21 +1043,40 @@ export async function run(context) {
let sealedCartonMarks = []
let sealedSourcePalletNos = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
- const sealed = await completeDropshipPalletization(context, {
- inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
- })
- sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
- sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
- ? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
- completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
- finalResponse = completed.response
- finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
- `pollAttempts=${completed.attempts}`, OPTIONS)
- context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
- finalMeasurement?.processStatus === 'WAIT_SPLIT'
- && finalMeasurement?.measureReviewStatus === 'APPROVED'
- && finalMeasurement?.measurementStatus === 'APPROVED',
- `processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
+ if (context.options?.prepareDropshipManualCount) {
+ const warehouseId = asId(summary?.warehouseId)
+ const pendingResponse = await context.request('GET',
+ `/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
+ const pending = context.apiSuccess(MODULE, '准备PDA人工清点任务', pendingResponse,
+ `inboundId=${inboundId}`, OPTIONS)
+ const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId)
+ context.assert(MODULE, '任务停在未领取、未清点、未归托状态', pendingResponse,
+ Boolean(asId(task?.taskId)) && task?.taskStatus === 'PENDING'
+ && Number(task?.countedCartons) === 0 && Number(task?.boundCartons) === 0
+ && Number(task?.totalCartons) === Number(finalMeasurement?.actualPackageCount),
+ `taskId=${asId(task?.taskId) || 'missing'}, status=${task?.taskStatus ?? 'missing'}, counted=${task?.countedCartons ?? 'missing'}`, OPTIONS)
+ context.createdResources.dropshipManualCount = {
+ taskId: asId(task?.taskId), inboundId, inboundCode, warehouseId,
+ warehouseName: task?.warehouseName || null, totalCartons: Number(task?.totalCartons || 0),
+ taskStatus: task?.taskStatus || null,
+ }
+ } else {
+ const sealed = await completeDropshipPalletization(context, {
+ inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
+ })
+ sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
+ sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
+ ? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
+ completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
+ finalResponse = completed.response
+ finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
+ `pollAttempts=${completed.attempts}`, OPTIONS)
+ context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
+ finalMeasurement?.processStatus === 'WAIT_SPLIT'
+ && finalMeasurement?.measureReviewStatus === 'APPROVED'
+ && finalMeasurement?.measurementStatus === 'APPROVED',
+ `processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
+ }
}
const approvedScanResponse = await multipartRequest(context, scanPath, {
diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs
index 56d45e27b..64bf7e032 100644
--- a/scripts/admin-test-env-modules/orders.mjs
+++ b/scripts/admin-test-env-modules/orders.mjs
@@ -402,6 +402,11 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
? context.context.waitSplitVariant.packageCounts : []
const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {}
+ const dropship = String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase()
+ === 'ONE_CLICK_DROPSHIP'
+ const mixedPackageCount = Math.max(1,
+ Number(context.context?.waitSplitVariant?.totalPackageCount || packageCountPerSku))
+ const mixedPackingGroupNo = `MIX-${context.marker}`.slice(0, 32)
const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
const receiver = isPrivate ? {
@@ -411,7 +416,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
} : null
return {
- fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
+ fulfillmentType: dropship ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
customerId: profile.id,
customerName: companyName,
transportMode: 'SEA', tradeTerm: 'FOB',
@@ -441,14 +446,17 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
cargoes: selectedBoxes.map((box, index) => {
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
- const packageCount = Number(packageCounts[index] || packageCountPerSku)
+ const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku)
+ const carton = dropship ? selectedBoxes[0] : box
+ const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
- lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
+ packingGroupNo: dropship ? mixedPackingGroupNo : null,
+ lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm,
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
- volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
+ volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1
index 42596bc14..8159aa84a 100644
--- a/scripts/agents-index-contract.ps1
+++ b/scripts/agents-index-contract.ps1
@@ -518,13 +518,13 @@ foreach ($pdaApplication in $pdaApplications) {
}
}
-# 海外 PDA 已开放拆柜收货、库内、换标和现场查询真实作业。
+# 海外 PDA 已开放拆柜收货、移库、托盘、换标和现场查询真实作业;库存盘点仅保留底层实现。
$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js'
$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath
Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关'
Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关'
+Assert-NotContains $overseasWorkbench "key: 'stocktake'" 'Overseas PDA 隐藏库存盘点入口'
foreach ($enabledOperation in @(
- @{ Name = '库存盘点'; Route = '/pages/stocktake/stocktake' },
@{ Name = '商品移库'; Route = '/pages/move/move' },
@{ Name = '换标收货'; Route = '/pages/relabel/relabel\?op=relabelReceive' },
@{ Name = '换标执行'; Route = '/pages/relabel/relabel\?op=relabelExecute' },
From 010475a4c3145a762eecd15b653c3fd2377072ef Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:21:14 +0800
Subject: [PATCH 48/63] fix: prepare mixed-SKU dropship acceptance data
---
.../service/InboundCartonLabelService.java | 14 +++++++++--
.../InboundCartonLabelServiceTest.java | 25 +++++++++++++++++++
scripts/admin-test-env-modules/README.md | 2 +-
scripts/admin-test-env-modules/orders.mjs | 1 -
4 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java
index 8713f0e8b..d26123817 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java
@@ -358,8 +358,18 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
Map> skuRowsByLabel) {
List lines = skuRowsByLabel.get(label.getId());
if (lines == null || lines.isEmpty()) return label.getSkuCode();
- return lines.stream().map(InboundCartonLabelSkuMapper.SkuRow::getSkuCode)
- .filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(" / "));
+ List skuCodes = lines.stream().map(InboundCartonLabelSkuMapper.SkuRow::getSkuCode)
+ .filter(StringUtils::hasText).map(String::trim).distinct().toList();
+ if (skuCodes.isEmpty()) return label.getSkuCode();
+ String joined = String.join(" / ", skuCodes);
+ if (joined.codePointCount(0, joined.length()) <= 64) return joined;
+ String suffix = " / +" + Math.max(0, skuCodes.size() - 1) + " SKU";
+ int firstLimit = 64 - suffix.codePointCount(0, suffix.length());
+ String first = skuCodes.get(0);
+ if (first.codePointCount(0, first.length()) > firstLimit) {
+ first = first.substring(0, first.offsetByCodePoints(0, firstLimit));
+ }
+ return first + suffix;
}
private void requireIds(long tenantId, long inboundId) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java
index e807067c1..505f3dfb2 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java
@@ -19,6 +19,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -98,6 +99,23 @@ class InboundCartonLabelServiceTest {
any(Long.class), any(String.class), any(), any(Integer.class), any(Integer.class));
}
+ @Test
+ void mixedCartonSkuSummaryStaysWithinPrintContractLimit() {
+ InboundCartonLabelEntity label = new InboundCartonLabelEntity();
+ label.setId(90001L);
+ List rows = List.of(
+ skuRow(90001L, "SKU-SMALL-ADMIN-API-20260911081504632"),
+ skuRow(90001L, "SKU-MEDIUM-ADMIN-API-20260911081504632"),
+ skuRow(90001L, "SKU-LARGE-ADMIN-API-20260911081504632"),
+ skuRow(90001L, "SKU-EXTRA-LARGE-ADMIN-API-20260911081504632"));
+
+ String summary = ReflectionTestUtils.invokeMethod(service, "displaySkuCodes", label,
+ Map.of(90001L, rows));
+
+ assertThat(summary.codePointCount(0, summary.length())).isLessThanOrEqualTo(64);
+ assertThat(summary).startsWith("SKU-SMALL-").endsWith(" / +3 SKU");
+ }
+
@Test
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
InboundOrderEntity inbound = inbound();
@@ -312,6 +330,13 @@ class InboundCartonLabelServiceTest {
units, new BigDecimal("60"), new BigDecimal("40"), new BigDecimal("30"), null, group);
}
+ private InboundCartonLabelSkuMapper.SkuRow skuRow(long labelId, String skuCode) {
+ InboundCartonLabelSkuMapper.SkuRow row = new InboundCartonLabelSkuMapper.SkuRow();
+ row.setCartonLabelId(labelId);
+ row.setSkuCode(skuCode);
+ return row;
+ }
+
private List labels(InboundOrderEntity inbound, int total) {
List values = new ArrayList<>();
for (int index = 1; index <= total; index++) {
diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md
index 526dd48b9..48f054289 100644
--- a/scripts/admin-test-env-modules/README.md
+++ b/scripts/admin-test-env-modules/README.md
@@ -39,7 +39,7 @@ export async function run(context) {
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
-一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。
+一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs
index 64bf7e032..296027a1d 100644
--- a/scripts/admin-test-env-modules/orders.mjs
+++ b/scripts/admin-test-env-modules/orders.mjs
@@ -444,7 +444,6 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
},
receiver,
cargoes: selectedBoxes.map((box, index) => {
- const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku)
const carton = dropship ? selectedBoxes[0] : box
From 87ae82a1149ff2461619b29627c0624a24c10d42 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:34:40 +0800
Subject: [PATCH 49/63] test: support manual dropship count fixtures
---
scripts/admin-test-env-acceptance.mjs | 9 ++++++---
scripts/admin-test-env-modules/inbound-measurement.mjs | 7 +++++--
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs
index f85a82438..ff17e07cf 100644
--- a/scripts/admin-test-env-acceptance.mjs
+++ b/scripts/admin-test-env-acceptance.mjs
@@ -776,10 +776,12 @@ export class AcceptanceRun {
captureWaitSplitFixture(sequence) {
const order = this.createdResources.orders || {}
const measurement = this.createdResources.inboundMeasurement || {}
+ const expectedProcessStatus = this.options.prepareDropshipManualCount ? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'
const ready = Boolean(order.orderId && measurement.inboundId
&& String(order.tenantId) === String(measurement.tenantId)
- && measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED')
- this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready,
+ && measurement.processStatus === expectedProcessStatus && measurement.finalStatus === 'APPROVED')
+ this.assert('wait-split-fixtures', this.options.prepareDropshipManualCount
+ ? `固定客户第 ${sequence} 单停在PDA清点前` : `固定客户第 ${sequence} 单停在待分柜`, null, ready,
`tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`,
{ scenario: 'fixed-customer-wait-split', deploymentBlocker: true })
if (!ready) return false
@@ -789,7 +791,8 @@ export class AcceptanceRun {
orderNo: order.orderNo || null, inboundId: String(measurement.inboundId),
inboundCode: order.inboundCode || null, processStatus: measurement.processStatus,
reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null,
- totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
+ totalPackageCount: measurement.actualPackageCount
+ || (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
palletCount: measurement.palletCount || null,
actualGrossWeightKg: measurement.actualGrossWeightKg || null,
actualNetWeightKg: measurement.actualNetWeightKg || null,
diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs
index 74ac017e7..55c430736 100644
--- a/scripts/admin-test-env-modules/inbound-measurement.mjs
+++ b/scripts/admin-test-env-modules/inbound-measurement.mjs
@@ -784,8 +784,10 @@ export async function run(context) {
measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS)
measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean))
- context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
- detailIds.size >= incrementalDetailCount
+ const mixedDropship = context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
+ context.assert(MODULE, mixedDropship
+ ? '混箱一件代发复用物理箱测量明细' : '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
+ mixedDropship ? detailIds.size === 1 : detailIds.size >= incrementalDetailCount
&& measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount,
`measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS)
}
@@ -1096,6 +1098,7 @@ export async function run(context) {
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
+ actualPackageCount: Number(finalMeasurement?.actualPackageCount || 0),
destinationType: context.createdResources?.orders?.destinationType || null,
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
From b6c66565b94afc19337066221ea6e26743f73e2c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 16:50:10 +0800
Subject: [PATCH 50/63] fix: unify same-carton order metrics
---
.../InternalOrderCargoController.java | 6 +++--
.../com/baiyue/order/dto/OrderRequests.java | 2 +-
.../MerchantInboundOrderListService.java | 9 +++++---
.../service/OrderApplicationService.java | 7 ++++--
.../service/OrderCargoPackageCounts.java | 13 +++++++++++
.../service/UnifiedOrderQueryService.java | 10 ++++++---
.../src/main/resources/openapi/order-v1.yaml | 2 +-
.../service/OrderCargoPackageCountsTest.java | 11 ++++++++++
.../service/UnifiedOrderQueryServiceTest.java | 22 +++++++++++++++++++
scripts/admin-test-env-modules/orders.mjs | 4 ++--
10 files changed, 72 insertions(+), 14 deletions(-)
diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
index 86aa868c8..a7d753780 100644
--- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
+++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
@@ -140,8 +140,10 @@ public class InternalOrderCargoController {
// 申报重量/体积必须属于当前订单。拆分子单未物化独立货物事实时不得复制根单整单总量。
List cargoes = orders.findCargo(tenantId, orderId);
return ApiResponse.success(new MeasurementDeclarationSnapshot(
- order.getReferenceId(), sum(cargoes, OrderCargoEntity::getGrossWeightKg),
- sum(cargoes, OrderCargoEntity::getVolumeCbm),
+ order.getReferenceId(), OrderCargoPackageCounts.physicalMetric(
+ cargoes, order.getFulfillmentType(), OrderCargoEntity::getGrossWeightKg),
+ OrderCargoPackageCounts.physicalMetric(
+ cargoes, order.getFulfillmentType(), OrderCargoEntity::getVolumeCbm),
cargoes.stream().map(OrderCargoEntity::getCargoName).filter(Objects::nonNull)
.map(String::trim).filter(value -> !value.isEmpty()).distinct()
.collect(Collectors.joining("、"))));
diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
index 13a22c321..48fa55833 100644
--- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
+++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
@@ -229,7 +229,7 @@ public final class OrderRequests {
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId,
@Schema(description = "Authoritative wood-product decision frozen with this cargo line / 木制品权威判定,随货物行冻结", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Boolean woodenProduct,
- @Schema(description = "Mixed-carton group. Cargo lines with the same value share physical cartons / 混箱组合号;相同组合号的货物行属于同一批物理箱", maxLength = 32)
+ @Schema(description = "Client-generated same-carton key. Lines with the same value share carton count, dimensions, weight and volume / 客户端添加同箱 SKU 时生成;同组物理箱字段只汇总一次", maxLength = 32)
@Size(max = 32) String packingGroupNo
) {
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
index 94ed30daf..a827da1c5 100644
--- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
+++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
@@ -107,9 +107,12 @@ public class MerchantInboundOrderListService {
int packageCount = OrderCargoPackageCounts.physical(cargoes, order.getFulfillmentType());
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
- BigDecimal grossWeight = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
- BigDecimal netWeight = sum(cargoes, OrderCargoEntity::getNetWeightKg);
- BigDecimal volume = sum(cargoes, OrderCargoEntity::getVolumeCbm);
+ BigDecimal grossWeight = OrderCargoPackageCounts.physicalMetric(
+ cargoes, order.getFulfillmentType(), OrderCargoEntity::getGrossWeightKg);
+ BigDecimal netWeight = OrderCargoPackageCounts.physicalMetric(
+ cargoes, order.getFulfillmentType(), OrderCargoEntity::getNetWeightKg);
+ BigDecimal volume = OrderCargoPackageCounts.physicalMetric(
+ cargoes, order.getFulfillmentType(), OrderCargoEntity::getVolumeCbm);
BigDecimal declaredAmount = sum(cargoes, OrderCargoEntity::getDeclaredValue);
List cargoColumns = cargoes.stream()
diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
index d9c3d0825..8f2a76c8b 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
@@ -798,9 +798,12 @@ public class OrderApplicationService {
|| !Objects.equals(first.packageType(), cargo.packageType())
|| !sameDecimal(first.lengthCm(), cargo.lengthCm())
|| !sameDecimal(first.widthCm(), cargo.widthCm())
- || !sameDecimal(first.heightCm(), cargo.heightCm()))) {
+ || !sameDecimal(first.heightCm(), cargo.heightCm())
+ || !sameDecimal(first.grossWeightKg(), cargo.grossWeightKg())
+ || !sameDecimal(first.netWeightKg(), cargo.netWeightKg())
+ || !sameDecimal(first.volumeCbm(), cargo.volumeCbm()))) {
throw new BizException(ResultCode.VALIDATION_FAILED,
- "同一混箱组合的箱数、包装类型和箱规必须一致:" + group);
+ "同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致");
}
}
}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
index 555357944..df1403a16 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
@@ -7,6 +7,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.math.BigDecimal;
+import java.util.function.Function;
/** Calculates physical cartons without double-counting SKU lines in a mixed-carton group. */
public final class OrderCargoPackageCounts {
@@ -21,4 +23,15 @@ public final class OrderCargoPackageCounts {
return countedGroups.add(group) ? count : 0;
}).sum();
}
+
+ /** Sums a physical-carton metric once per same-carton group. */
+ public static BigDecimal physicalMetric(List cargoes, String fulfillmentType,
+ Function getter) {
+ Set countedGroups = new HashSet<>();
+ List values = cargoes.stream().filter(Objects::nonNull).filter(cargo -> {
+ String group = StringUtils.hasText(cargo.getPackingGroupNo()) ? cargo.getPackingGroupNo().trim() : null;
+ return !"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null || countedGroups.add(group);
+ }).map(getter).filter(Objects::nonNull).toList();
+ return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
}
diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
index e40838a64..4aa72693c 100644
--- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
+++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
@@ -508,9 +508,12 @@ public class UnifiedOrderQueryService {
Integer totalPackageCount = hasPackageCount
? OrderCargoPackageCounts.physical(cargoes, fulfillmentType)
: null;
- BigDecimal grossWeightKg = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
- BigDecimal netWeightKg = sum(cargoes, OrderCargoEntity::getNetWeightKg);
- BigDecimal volumeCbm = sum(cargoes, OrderCargoEntity::getVolumeCbm);
+ BigDecimal grossWeightKg = OrderCargoPackageCounts.physicalMetric(
+ cargoes, fulfillmentType, OrderCargoEntity::getGrossWeightKg);
+ BigDecimal netWeightKg = OrderCargoPackageCounts.physicalMetric(
+ cargoes, fulfillmentType, OrderCargoEntity::getNetWeightKg);
+ BigDecimal volumeCbm = OrderCargoPackageCounts.physicalMetric(
+ cargoes, fulfillmentType, OrderCargoEntity::getVolumeCbm);
return new CargoSummary(skuNos, shipmentIds, referenceIds, totalQuantity, totalPackageCount,
grossWeightKg, netWeightKg, volumeCbm);
}
@@ -520,6 +523,7 @@ public class UnifiedOrderQueryService {
List values = cargoes.stream().map(value).filter(Objects::nonNull).toList();
return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
}
+
}
private String inboundMainStatus(String status) {
if (status == null) return null;
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 1096cd56b..ad2f90bfa 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -1262,7 +1262,7 @@ components:
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量;提交时必填}
packageCount: {type: integer, minimum: 1, description: 箱数}
- packingGroupNo: {type: string, maxLength: 32, description: 一件代发混箱组合号;相同组合号的货物行共享同一批物理箱,组内箱数、包装类型和箱规必须一致}
+ packingGroupNo: {type: string, maxLength: 32, description: 一件代发同箱关联键,由客户端在添加同箱 SKU 时生成;同组货物行共享物理箱,箱数、包装类型、箱规、重量和体积必须一致且汇总时只计算一次}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
totalQuantity: {type: integer, minimum: 1, description: 总数量;提交时必填且必须等于每箱数量 × 箱数}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
index e05da0868..4226fca70 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
@@ -4,6 +4,7 @@ import com.baiyue.order.entity.OrderCargoEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
+import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,6 +19,16 @@ class OrderCargoPackageCountsTest {
List.of(skuA, skuB, separate), "ONE_CLICK_DROPSHIP")).isEqualTo(14);
assertThat(OrderCargoPackageCounts.physical(
List.of(skuA, skuB, separate), "STANDARD")).isEqualTo(24);
+
+ skuA.setGrossWeightKg(new BigDecimal("12.5"));
+ skuB.setGrossWeightKg(new BigDecimal("12.5"));
+ separate.setGrossWeightKg(new BigDecimal("3.0"));
+ assertThat(OrderCargoPackageCounts.physicalMetric(
+ List.of(skuA, skuB, separate), "ONE_CLICK_DROPSHIP", OrderCargoEntity::getGrossWeightKg))
+ .isEqualByComparingTo("15.5");
+ assertThat(OrderCargoPackageCounts.physicalMetric(
+ List.of(skuA, skuB, separate), "STANDARD", OrderCargoEntity::getGrossWeightKg))
+ .isEqualByComparingTo("28.0");
}
private OrderCargoEntity cargo(int count, String group) {
diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
index 7f90979c8..f502162aa 100644
--- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
+++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java
@@ -131,6 +131,28 @@ class UnifiedOrderQueryServiceTest {
verify(orders).findCargoByOrderIds(10001L, List.of(77L));
}
+ @Test
+ void dropshipMixedSkuRowsCountSharedCartonVolumeOnlyOnce() {
+ UnifiedOrderQuery query = query(null);
+ OrderEntity order = order(OrderStatus.APPROVED);
+ order.setFulfillmentType("ONE_CLICK_DROPSHIP");
+ OrderCargoEntity first = new OrderCargoEntity();
+ first.setOrderId(77L); first.setPackageCount(10); first.setPackingGroupNo("PG-1");
+ first.setVolumeCbm(new BigDecimal("0.375"));
+ OrderCargoEntity second = new OrderCargoEntity();
+ second.setOrderId(77L); second.setPackageCount(10); second.setPackingGroupNo("PG-1");
+ second.setVolumeCbm(new BigDecimal("0.375"));
+ when(orders.unifiedPage(eq(10001L), eq(query), any()))
+ .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
+ when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of()));
+ when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(first, second));
+
+ assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
+ assertThat(row.totalPackageCount()).isEqualTo(10);
+ assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375");
+ });
+ }
+
@Test
void listAggregatesShipmentAndReferenceIdsFromAllCargoLines() {
UnifiedOrderQuery query = query(null);
diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs
index 296027a1d..8850c800c 100644
--- a/scripts/admin-test-env-modules/orders.mjs
+++ b/scripts/admin-test-env-modules/orders.mjs
@@ -454,9 +454,9 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
packingGroupNo: dropship ? mixedPackingGroupNo : null,
lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm,
- unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
+ unitsPerPackage, packageCount, grossWeightKg: (dropship ? 10 : 10 + index) * packageCount,
volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
- netWeightKg: (9 + index) * packageCount,
+ netWeightKg: (dropship ? 9 : 9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
From 1683977e0bfd44dc8eecebbc89146c870629bff9 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 17:01:56 +0800
Subject: [PATCH 51/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../baiyue/firstleg/mapper/LoadingListQueryMapper.java | 8 ++++++--
.../repository/LoadingListQueryMapperContractTest.java | 6 ++++++
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
index 48d6f47cb..d1fd0820c 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java
@@ -23,7 +23,11 @@ public interface LoadingListQueryMapper {
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
- b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
+ b.channel_code,
+ (SELECT MIN(outbound.confirmed_at) FROM fl_outbound_dispatch outbound
+ WHERE outbound.container_booking_id = p.container_booking_id AND outbound.confirmed_at IS NOT NULL
+ AND outbound.reversed_at IS NULL AND outbound.status = 'WAITING_FOR_LOADING') AS outbound_confirmed_at,
+ b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
@@ -52,7 +56,7 @@ public interface LoadingListQueryMapper {
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
- b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
+ b.channel_code, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type
""";
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java
index 659703cb6..063b025bb 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java
@@ -34,6 +34,10 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i")
+ .contains("FROM fl_outbound_dispatch outbound")
+ .contains("outbound.container_booking_id = p.container_booking_id")
+ .contains("AS outbound_confirmed_at")
+ .doesNotContain("b.outbound_confirmed_at")
.doesNotContain("preload_pushed_at")
.contains("p.plan_status IN ('EDITING', 'LOADING')")
.doesNotContain("active_item.deleted = 0")
@@ -47,6 +51,8 @@ class LoadingListQueryMapperContractTest {
.doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed)
.contains("FROM fl_loading_plan p")
+ .contains("FROM fl_outbound_dispatch outbound")
+ .doesNotContain("b.outbound_confirmed_at")
.contains("p.plan_status = 'COMPLETED'")
.contains("customer_item.account_id = #{query.customerId}")
.contains("b.warehouse_id = #{query.warehouseId}")
From c8e82458538a01f70c73c4f44765cbc78dee6edd Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 17:12:03 +0800
Subject: [PATCH 52/63] feat: allow shared dropship pallets
---
firstleg/AGENTS.md | 2 +-
.../mapper/DropshipPalletizationMapper.java | 21 +++++++++++++------
.../service/DropshipPalletizationService.java | 6 +++---
.../DropshipPalletizationSqlContractTest.java | 15 +++++++++++++
.../DropshipPalletizationServiceTest.java | 18 ++++++++++++++++
5 files changed, 52 insertions(+), 10 deletions(-)
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index 32956daa5..b9d5265f7 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -65,7 +65,7 @@
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
-- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
+- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
index 75dbd1ec7..5c5715965 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
@@ -56,7 +56,7 @@ public interface DropshipPalletizationMapper {
@Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
- @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
+ @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo} FOR UPDATE") PalletRow palletByNo(@Param("palletNo") String palletNo);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId}") int palletCount(@Param("taskId") long taskId);
@Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId);
@Select("""
@@ -67,11 +67,18 @@ public interface DropshipPalletizationMapper {
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("""
SELECT p.* FROM fl_dropship_pallet p
- WHERE p.task_id=#{taskId} ORDER BY p.id
+ WHERE p.task_id=#{taskId}
+ OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})
+ ORDER BY p.id
""") List pallets(@Param("taskId") long taskId);
@Select("""
SELECT p.* FROM fl_dropship_pallet p
- WHERE p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId} ORDER BY p.id
+ WHERE ((p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId})
+ AND EXISTS(SELECT 1 FROM fl_inbound_order own
+ WHERE own.id=#{inboundId} AND own.tenant_id=#{tenantId} AND own.process_status='WAIT_PALLETIZE'))
+ OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c
+ WHERE c.pallet_id=p.id AND c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId})
+ ORDER BY p.id
""")
List palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
@@ -162,15 +169,17 @@ public interface DropshipPalletizationMapper {
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
- SELECT COUNT(*) FROM fl_dropship_pallet p
- WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN'
+ SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet_carton c
+ JOIN fl_dropship_pallet p ON p.id=c.pallet_id
+ WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN'
""") int openPalletCount(@Param("taskId") long taskId);
@Select("SELECT DISTINCT task_id FROM fl_dropship_pallet_carton WHERE pallet_id=#{palletId} ORDER BY task_id")
List taskIdsByPallet(@Param("palletId") long palletId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
- AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
+ AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
+ WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id)
FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
index c3a569d5f..224386def 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
@@ -158,8 +158,8 @@ public class DropshipPalletizationService {
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
- if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不是本单测量审核后生成的托盘码");
- if(!Objects.equals(existing.getTaskId(),task.getId())||!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前任务");
+ if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
+ if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
@@ -173,7 +173,7 @@ public class DropshipPalletizationService {
private Task view(TaskRow row){
List pallets=mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList();
int expected=row.getExpectedPalletCount()==null?0:row.getExpectedPalletCount();
- int generated=pallets.size();
+ int generated=mapper.palletCount(row.getId());
boolean palletSetupReady=expected>0 && generated==expected;
return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java
index d44557cc2..b549689c2 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java
@@ -61,4 +61,19 @@ class DropshipPalletizationSqlContractTest {
.contains("DROP INDEX uk_loading_item_plan_detail")
.contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)");
}
+
+ @Test
+ void sharedPalletsAreVisibleAndOnlyParticipatingOpenPalletsBlockCompletion() throws Exception {
+ String pallets = DropshipPalletizationMapper.class.getDeclaredMethod("pallets", long.class)
+ .getAnnotation(Select.class).value()[0];
+ String open = DropshipPalletizationMapper.class.getDeclaredMethod("openPalletCount", long.class)
+ .getAnnotation(Select.class).value()[0];
+ String complete = DropshipPalletizationMapper.class.getDeclaredMethod("completeTask", long.class)
+ .getAnnotation(org.apache.ibatis.annotations.Update.class).value()[0];
+
+ assertThat(pallets).contains("c.pallet_id=p.id AND c.task_id=#{taskId}");
+ assertThat(open).contains("fl_dropship_pallet_carton c").contains("c.task_id=#{taskId}");
+ assertThat(complete).contains("fl_dropship_pallet_carton c JOIN fl_dropship_pallet p")
+ .doesNotContain("p.task_id=#{taskId}");
+ }
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java
index a6b6d5683..b985a88d6 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java
@@ -53,6 +53,24 @@ class DropshipPalletizationServiceTest {
verify(mapper).insertCartonSku(anyLong(),anyLong(),eq(40L),eq("SKU-1"),any(),eq(12),eq(12),eq("MATCH"),isNull(),eq(1));
}
+ @Test void cartonCanBindToAnOpenPalletGeneratedForAnotherTaskInTheSameWarehouse(){
+ TaskRow current=task(); PalletRow shared=pallet(); shared.setTaskId(99L); shared.setTenantId(2L); shared.setInboundId(88L);
+ when(mapper.task(10L,100L)).thenReturn(current); when(mapper.palletCount(10L)).thenReturn(2);
+ when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(shared);
+ when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton());
+ when(mapper.pallet(10L,20L,100L)).thenReturn(shared); when(mapper.cartons(20L)).thenReturn(java.util.List.of());
+ service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三");
+ verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),anyLong(),anyLong(),anyString(),any(),eq("BOX-1"),anyInt(),anyInt(),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三"));
+ }
+
+ @Test void palletFromAnotherWarehouseCannotBeMixed(){
+ PalletRow other=pallet(); other.setWarehouseId(101L);
+ when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2);
+ when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(other);
+ assertThatThrownBy(()->service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三"))
+ .isInstanceOf(BizException.class).hasMessageContaining("当前作业仓库");
+ }
+
@Test void unknownPalletCodeCannotCreateAnAdHocPallet(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var request=countRequest("DP-260911-9999");
From 458a4cfa50bd16f7f7aca99c1ef980cff08219f4 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 17:20:56 +0800
Subject: [PATCH 53/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 8 +++
.../dashboard/DashboardMetricsDtos.java | 16 ++++++
.../dashboard/DashboardMetricsMapper.java | 52 +++++++++++++++++++
.../dashboard/DashboardMetricsService.java | 24 +++++++++
.../PlatformDashboardMetricsController.java | 25 +++++++++
.../DashboardMetricsContractTest.java | 33 ++++++++++++
.../DashboardMetricsServiceTest.java | 43 +++++++++++++++
.../QualificationSchemaContractTest.java | 7 ++-
8 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index c1f6591d6..132ea8880 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -1434,6 +1434,7 @@ CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMEN
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
+CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name', "VARCHAR(255) NULL COMMENT '上传时原始文件名' AFTER document_url");
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
@@ -15239,6 +15240,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'created_at'
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'deleted', '逻辑删除标记');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'document_url', '资质文件存储地址');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'id', '资质记录ID');
+CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'original_file_name', '上传时原始文件名');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'qualification_type', '客户资质类型');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'review_comment', '客户资质审核意见');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'status', '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回');
@@ -15789,6 +15791,12 @@ BEGIN
'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建';
END IF;
+ IF (SELECT COUNT(*) FROM information_schema.columns
+ WHERE table_schema='baiyue_iam' AND table_name='iam_tenant_qualification'
+ AND column_name='original_file_name') <> 1 THEN
+ SIGNAL SQLSTATE '45000'
+ SET MESSAGE_TEXT = '统一迁移失败:IAM客户资质缺少原始文件名列';
+ END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_fee'
AND ((column_name='baseline_chargeable_quantity' AND is_nullable='NO'
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java
new file mode 100644
index 000000000..00e619e54
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsDtos.java
@@ -0,0 +1,16 @@
+package com.baiyue.firstleg.dashboard;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+public final class DashboardMetricsDtos {
+ private DashboardMetricsDtos() {}
+
+ public record OperationalMetrics(
+ long inTransitContainerCount,
+ BigDecimal inTransitTeu,
+ long unknownContainerTypeCount,
+ long pendingCustomsOrderCount,
+ LocalDateTime asOf
+ ) {}
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java
new file mode 100644
index 000000000..b803f27fc
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsMapper.java
@@ -0,0 +1,52 @@
+package com.baiyue.firstleg.dashboard;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Mapper
+public interface DashboardMetricsMapper {
+ @Select("""
+ SELECT transit.container_count inTransitContainerCount,
+ transit.total_teu inTransitTeu,
+ transit.unknown_container_type_count unknownContainerTypeCount,
+ customs.pending_customs_order_count pendingCustomsOrderCount,
+ CURRENT_TIMESTAMP(3) asOf
+ FROM (
+ SELECT COUNT(*) container_count,
+ COALESCE(SUM(container.teu), 0) total_teu,
+ COALESCE(SUM(CASE WHEN container.teu IS NULL THEN 1 ELSE 0 END), 0) unknown_container_type_count
+ FROM (
+ SELECT DISTINCT booking.id,
+ CASE
+ WHEN UPPER(TRIM(booking.container_type)) LIKE '20%' THEN 1.0
+ WHEN UPPER(TRIM(booking.container_type)) LIKE '40%' THEN 2.0
+ WHEN UPPER(TRIM(booking.container_type)) LIKE '45%' THEN 2.25
+ ELSE NULL
+ END teu
+ FROM fl_container_booking booking
+ JOIN fl_ocean_booking ocean ON ocean.id = booking.ocean_booking_id
+ WHERE booking.actual_sailing_at IS NOT NULL
+ AND booking.actual_warehouse_arrival_at IS NULL
+ AND booking.record_status = 'ACTIVE'
+ AND ocean.status <> 'CANCELLED'
+ ) container
+ ) transit
+ CROSS JOIN (
+ SELECT COUNT(DISTINCT inbound.order_id) pending_customs_order_count
+ FROM fl_customs_container customs_container
+ JOIN fl_loading_plan_item item
+ ON item.loading_plan_id = customs_container.loading_plan_id AND item.deleted = 0
+ JOIN fl_inbound_order inbound
+ ON inbound.id = item.inbound_id AND inbound.tenant_id = item.account_id AND inbound.deleted = 0
+ WHERE customs_container.status <> 'COMPLETED'
+ ) customs
+ """)
+ MetricsProjection read();
+
+ record MetricsProjection(Long inTransitContainerCount, BigDecimal inTransitTeu,
+ Long unknownContainerTypeCount, Long pendingCustomsOrderCount,
+ LocalDateTime asOf) {}
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java
new file mode 100644
index 000000000..6f3671b3c
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/DashboardMetricsService.java
@@ -0,0 +1,24 @@
+package com.baiyue.firstleg.dashboard;
+
+import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+
+@Service
+public class DashboardMetricsService {
+ @Resource private DashboardMetricsMapper mapper;
+
+ public OperationalMetrics read() {
+ var row = mapper.read();
+ if (row == null) return new OperationalMetrics(0, BigDecimal.ZERO, 0, 0, null);
+ return new OperationalMetrics(value(row.inTransitContainerCount()),
+ row.inTransitTeu() == null ? BigDecimal.ZERO : row.inTransitTeu(),
+ value(row.unknownContainerTypeCount()), value(row.pendingCustomsOrderCount()), row.asOf());
+ }
+
+ private static long value(Long value) {
+ return value == null ? 0 : value;
+ }
+}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java
new file mode 100644
index 000000000..350b1ab22
--- /dev/null
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dashboard/PlatformDashboardMetricsController.java
@@ -0,0 +1,25 @@
+package com.baiyue.firstleg.dashboard;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
+import com.baiyue.firstleg.service.FirstlegScopeGuard;
+import jakarta.annotation.Resource;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/platform/v1/first-leg/dashboard")
+public class PlatformDashboardMetricsController {
+ @Resource private DashboardMetricsService service;
+
+ @GetMapping("/operational-metrics")
+ @PreAuthorize("hasAuthority('platform:firstleg:container:list') and hasAuthority('platform:firstleg:customs:list')")
+ public ApiResponse metrics(@AuthenticationPrincipal AuthenticatedUser user) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.read());
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
new file mode 100644
index 000000000..2521681d8
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
@@ -0,0 +1,33 @@
+package com.baiyue.firstleg.dashboard;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DashboardMetricsContractTest {
+ @Test void exposesThePlatformDashboardEndpointWithBothReadPermissions() throws Exception {
+ assertThat(PlatformDashboardMetricsController.class.getAnnotation(RequestMapping.class).value())
+ .containsExactly("/api/platform/v1/first-leg/dashboard");
+ var method = PlatformDashboardMetricsController.class.getDeclaredMethod(
+ "metrics", com.baiyue.common.security.AuthenticatedUser.class);
+ assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/operational-metrics");
+ assertThat(method.getAnnotation(PreAuthorize.class).value())
+ .contains("platform:firstleg:container:list", "platform:firstleg:customs:list");
+ }
+
+ @Test void queryCountsPhysicalTransitContainersAndDistinctPendingOrders() {
+ var sql = Arrays.stream(DashboardMetricsMapper.class.getDeclaredMethods())
+ .filter(method -> method.getName().equals("read"))
+ .findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value();
+ var statement = String.join("\n", sql);
+ assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL",
+ "booking.record_status = 'ACTIVE'", "ocean.status <> 'CANCELLED'",
+ "COUNT(DISTINCT inbound.order_id)", "customs_container.status <> 'COMPLETED'",
+ "item.deleted = 0", "inbound.deleted = 0", "LIKE '20%'", "LIKE '40%'", "LIKE '45%'");
+ }
+}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java
new file mode 100644
index 000000000..c25fb5d20
--- /dev/null
+++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsServiceTest.java
@@ -0,0 +1,43 @@
+package com.baiyue.firstleg.dashboard;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DashboardMetricsServiceTest {
+ @Mock DashboardMetricsMapper mapper;
+ @InjectMocks DashboardMetricsService service;
+
+ @Test void preservesOperationalCountsAndDecimalTeu() {
+ var asOf = LocalDateTime.of(2026, 9, 11, 12, 0);
+ when(mapper.read()).thenReturn(new DashboardMetricsMapper.MetricsProjection(
+ 3L, new BigDecimal("4.25"), 1L, 7L, asOf));
+
+ var result = service.read();
+
+ assertThat(result.inTransitContainerCount()).isEqualTo(3);
+ assertThat(result.inTransitTeu()).isEqualByComparingTo("4.25");
+ assertThat(result.unknownContainerTypeCount()).isEqualTo(1);
+ assertThat(result.pendingCustomsOrderCount()).isEqualTo(7);
+ assertThat(result.asOf()).isEqualTo(asOf);
+ }
+
+ @Test void returnsZerosWhenTheDatabaseProjectionIsAbsent() {
+ when(mapper.read()).thenReturn(null);
+
+ var result = service.read();
+
+ assertThat(result.inTransitContainerCount()).isZero();
+ assertThat(result.inTransitTeu()).isEqualByComparingTo("0");
+ assertThat(result.pendingCustomsOrderCount()).isZero();
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
index fff5b2223..547e94802 100644
--- a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java
@@ -21,6 +21,11 @@ class QualificationSchemaContractTest {
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
- assertThat(unified).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
+ assertThat(unified)
+ .contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'")
+ .contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name',")
+ .contains("table_name='iam_tenant_qualification'")
+ .contains("column_name='original_file_name'")
+ .contains("统一迁移失败:IAM客户资质缺少原始文件名列");
}
}
From 63ce4c48a7dd9e19b018fc211df70eca354d9231 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 17:27:27 +0800
Subject: [PATCH 54/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
firstleg/AGENTS.md | 2 +-
...atformDropshipPalletizationController.java | 12 ++++
.../mapper/DropshipPalletizationMapper.java | 5 +-
.../service/DropshipPalletizationService.java | 6 ++
.../InboundCartonLabelPrintService.java | 34 +++++++---
.../openapi/dashboard-metrics-v1.yaml | 64 +++++++++++++++++++
.../openapi/dropship-palletization-v1.yaml | 8 +++
.../DashboardMetricsContractTest.java | 10 +++
.../InboundCartonLabelPrintServiceTest.java | 25 ++++++++
9 files changed, 154 insertions(+), 12 deletions(-)
create mode 100644 firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index b9d5265f7..9bc650679 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -29,7 +29,7 @@
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
-| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
+| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java
index 1ca74b2fa..59b3c9e17 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java
@@ -129,6 +129,18 @@ public class PlatformDropshipPalletizationController {
return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId);
}
+ @GetMapping("/tasks/{taskId}/pallets/print")
+ @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
+ public ResponseEntity printTaskPallets(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Positive long taskId,
+ @RequestParam @Positive long warehouseId,
+ @RequestHeader(value = "X-Request-Id", required = false) String requestId) {
+ FirstlegScopeGuard.platform(user);
+ Task task = service.task(taskId, warehouseId, user.userId());
+ return printService.generateDropshipPallets(
+ service.generatedPallets(taskId, warehouseId, user.userId()), task.inboundCode(), requestId);
+ }
+
@GetMapping("/inbound-orders/{inboundId}/pallets")
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
public ApiResponse> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
index 5c5715965..f639861df 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java
@@ -67,10 +67,11 @@ public interface DropshipPalletizationMapper {
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("""
SELECT p.* FROM fl_dropship_pallet p
- WHERE p.task_id=#{taskId}
- OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})
+ WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})
ORDER BY p.id
""") List pallets(@Param("taskId") long taskId);
+ @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id")
+ List generatedPallets(@Param("taskId") long taskId);
@Select("""
SELECT p.* FROM fl_dropship_pallet p
WHERE ((p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId})
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
index 224386def..e28433341 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java
@@ -156,6 +156,12 @@ public class DropshipPalletizationService {
return code;
}
+ public List generatedPallets(long taskId,long warehouseId,long operatorId){
+ TaskRow task=requireTask(taskId,warehouseId);
+ if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
+ return mapper.generatedPallets(taskId).stream().map(p->palletView(p,task.getInboundCode())).toList();
+ }
+
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java
index b142007c3..a2cd51904 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java
@@ -117,6 +117,30 @@ public class InboundCartonLabelPrintService {
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */
public ResponseEntity generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
+ return generateDropshipPallets(List.of(pallet), inboundCode, requestId);
+ }
+
+ /** 一票预生成托盘码合并为一个多页 PDF,避免 PDA 展开或逐个下载大量空托盘。 */
+ public ResponseEntity generateDropshipPallets(List pallets, String inboundCode, String requestId) {
+ if (pallets == null || pallets.isEmpty()) {
+ throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
+ }
+ List documents = pallets.stream()
+ .map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList();
+ String fileName = pallets.size() == 1
+ ? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
+ : inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
+ PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
+ "DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
+ fileName, documents);
+ try {
+ return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
+ } catch (FeignException | NoFallbackAvailableException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
+ }
+ }
+
+ private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
}
@@ -127,17 +151,9 @@ public class InboundCartonLabelPrintService {
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(),
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity())))
.toList();
- PrintPdfClient.DropshipPalletDocument document = new PrintPdfClient.DropshipPalletDocument(
+ return new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()),
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows);
- PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
- "DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
- pallet.palletNo() + "-A4托盘信息单.pdf", List.of(document));
- try {
- return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
- } catch (FeignException | NoFallbackAvailableException exception) {
- throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
- }
}
private int number(Integer value) {
diff --git a/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml b/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml
new file mode 100644
index 000000000..3c7c73c2c
--- /dev/null
+++ b/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml
@@ -0,0 +1,64 @@
+openapi: 3.0.3
+info:
+ title: Platform dashboard operational metrics
+ version: 1.0.0
+paths:
+ /api/platform/v1/first-leg/dashboard/operational-metrics:
+ get:
+ summary: 查询首页货柜与清关实时指标
+ description: >-
+ 在途柜量按已实际开船且尚未到达海外仓的有效物理柜去重,TEU 按 20*=1、40*=2、45*=2.25
+ 折算,未知箱型不计入 TEU;待清关票数按已进入整柜清关且清关未完成的去重订单统计。
+ 本接口只返回当前时点事实,不提供历史环比。
+ responses:
+ '200':
+ description: 查询成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OperationalMetricsEnvelope'
+ '401': {description: 需要登录}
+ '403': {description: 需要货柜列表与报关列表权限}
+components:
+ schemas:
+ OperationalMetricsEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code: {type: string, example: '200'}
+ message: {type: string}
+ data: {$ref: '#/components/schemas/OperationalMetrics'}
+ OperationalMetrics:
+ type: object
+ required:
+ - inTransitContainerCount
+ - inTransitTeu
+ - unknownContainerTypeCount
+ - pendingCustomsOrderCount
+ properties:
+ inTransitContainerCount:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 已开船未到海外仓的物理柜数
+ inTransitTeu:
+ type: number
+ format: decimal
+ multipleOf: 0.01
+ minimum: 0
+ description: 已知箱型的在途柜 TEU 合计
+ unknownContainerTypeCount:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 未计入 TEU 的未知箱型在途柜数
+ pendingCustomsOrderCount:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 已进入整柜清关且清关未完成的去重订单数
+ asOf:
+ type: string
+ format: date-time
+ nullable: true
+ description: 数据库统计时点
diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
index afedbe05a..267fe9015 100644
--- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
+++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
@@ -76,6 +76,14 @@ paths:
- {$ref: '#/components/parameters/WarehouseId'}
responses:
'200': {description: A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
+ /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/print:
+ get:
+ summary: 合并获取本票全部预生成托盘码的多页 A4 PDF
+ description: 每个预生成托盘固定一页,PDA 无需展开托盘列表或逐个下载,最多按本票预计托数生成。
+ x-required-authority: platform:firstleg:pda:view
+ parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}]
+ responses:
+ '200': {description: 多页 A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
get:
summary: Admin 查询入仓单权威托盘结果
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
index 2521681d8..e34d835d8 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java
@@ -1,11 +1,13 @@
package com.baiyue.firstleg.dashboard;
import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Arrays;
+import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@@ -30,4 +32,12 @@ class DashboardMetricsContractTest {
"COUNT(DISTINCT inbound.order_id)", "customs_container.status <> 'COMPLETED'",
"item.deleted = 0", "inbound.deleted = 0", "LIKE '20%'", "LIKE '40%'", "LIKE '45%'");
}
+
+ @Test void openApiDocumentsTheCurrentPointInTimeDefinitions() throws Exception {
+ var openApi = new ClassPathResource("openapi/dashboard-metrics-v1.yaml")
+ .getContentAsString(StandardCharsets.UTF_8);
+ assertThat(openApi).contains("/api/platform/v1/first-leg/dashboard/operational-metrics:",
+ "inTransitContainerCount:", "inTransitTeu:", "unknownContainerTypeCount:",
+ "pendingCustomsOrderCount:", "本接口只返回当前时点事实,不提供历史环比");
+ }
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java
index a73362faa..469493151 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java
@@ -69,6 +69,31 @@ class InboundCartonLabelPrintServiceTest {
});
}
+ @Test
+ void multiplePreGeneratedPalletsAreReturnedInOnePdf() {
+ Pallet first = dropshipPallet("OPEN");
+ Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0,
+ BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_PDF);
+ when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.eq("request-all"), org.mockito.ArgumentMatchers.any()))
+ .thenReturn(new ResponseEntity<>(new byte[] {4, 5, 6}, headers,
+ org.springframework.http.HttpStatus.OK));
+
+ ResponseEntity response = service.generateDropshipPallets(
+ List.of(first, second), "PCHK2608040174", "request-all");
+
+ ArgumentCaptor request =
+ ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
+ verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"),
+ org.mockito.ArgumentMatchers.eq("request-all"), request.capture());
+ assertThat(request.getValue().documents()).extracting("palletNo")
+ .containsExactly("DP-260911-0001", "DP-260911-0002");
+ assertThat(request.getValue().downloadFileName()).contains("2托");
+ assertThat(response.getBody()).containsExactly(4, 5, 6);
+ }
+
@Test
void forwardsStableLabelsAndOnlySafePdfHeaders() {
when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch());
From 30d6896c8a5c3137ef6fd2216853ab6769774a07 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 17:29:05 +0800
Subject: [PATCH 55/63] docs: clarify scan-first pallet workflow
---
.../src/main/resources/openapi/dropship-palletization-v1.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
index 267fe9015..afa76cfd2 100644
--- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
+++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 一件代发国内清点组托 API
version: 3.0.0
- description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可立即打印。PDA 支持先扫箱或先扫托,逐托封托后完成入库。
+ description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
@@ -35,7 +35,7 @@ paths:
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
post:
summary: 逐箱确认 SKU 实收数量
- description: 未传 palletNo 时进入待归托区;传入本任务预生成的 OPEN 托盘码时直接归托。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
+ description: 未传 palletNo 时进入待归托区;传入同一国内作业仓的 OPEN 托盘码时直接归托。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
From 019db2900a029414895cf2fa8c8ed3c13197427a Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 18:14:29 +0800
Subject: [PATCH 56/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
firstleg/AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index 9bc650679..a51889f47 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -88,7 +88,7 @@
- 新增模拟柜只保存智能装柜的 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` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
-- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
+- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`,也可用该托任一成员 `boxCode` 反查所属整托;两者最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
From 06b9f3eda717babf294d90aa064e866bf6bf246c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 18:24:18 +0800
Subject: [PATCH 57/63] fix: persist PDA picking progress incrementally
---
firstleg/AGENTS.md | 2 +-
.../com/baiyue/firstleg/dto/LoadingPickDtos.java | 2 +-
.../firstleg/mapper/LoadingPickItemMapper.java | 2 +-
.../baiyue/firstleg/service/LoadingPickService.java | 6 +++---
.../LoadingPickItemMapperContractTest.java | 12 ++++++++++++
5 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index a51889f47..86599c08d 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -88,7 +88,7 @@
- 新增模拟柜只保存智能装柜的 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` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
-- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`,也可用该托任一成员 `boxCode` 反查所属整托;两者最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
+- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java
index 0827228d8..c3b7df976 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java
@@ -47,7 +47,7 @@ public final class LoadingPickDtos {
) {
}
- /** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */
+ /** PDA 已即时提交的明细不重复包含;收尾时服务端原子处理剩余全部待拣明细。 */
public record BatchRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Long loadingPlanId,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java
index ef54c006a..b74c441d5 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java
@@ -199,7 +199,7 @@ public interface LoadingPickItemMapper extends BaseMapper
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
- AND picked_package_count < #{finalCount}
+ AND picked_package_count <= #{finalCount}
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
index a0a478702..d2ff2c773 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
@@ -405,7 +405,7 @@ public class LoadingPickService {
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
- throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
+ throw conflict("部分拣货最终箱数不能小于已拣箱数且必须小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
@@ -1068,9 +1068,9 @@ public class LoadingPickService {
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.finalPickedPackageCount() == null
- || requestItem.finalPickedPackageCount() <= current
+ || requestItem.finalPickedPackageCount() < current
|| requestItem.finalPickedPackageCount() >= planned) {
- throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数");
+ throw conflict("PARTIAL最终箱数不能小于已拣且必须小于计划箱数");
}
} else if ("SKIP".equals(action)) {
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java
index 3050653cc..f7c09f27e 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java
@@ -100,5 +100,17 @@ class LoadingPickItemMapperContractTest {
assertThat(pick.getAnnotation(Update.class).value()[0])
.doesNotContain("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
+ }
+
+ @Test
+ void partialCanFinalizeTheQuantityAlreadyPersistedByIncrementalScanning() throws Exception {
+ Method partial = LoadingPickItemMapper.class.getDeclaredMethod("partial", long.class, int.class,
+ int.class, long.class);
+ String sql = partial.getAnnotation(Update.class).value()[0];
+
+ assertThat(sql)
+ .contains("item_status IN ('PENDING', 'PICKING')")
+ .contains("picked_package_count <= #{finalCount}")
+ .contains("#{finalCount} < planned_package_count");
}
}
From dcd079b5b7d7644a7b2f0e4504746ee82ad9a1fa Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 18:43:57 +0800
Subject: [PATCH 58/63] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 14 ++++++-
.../PlatformPriceBookController.java | 11 +++++
.../firstleg/domain/PriceBookStatus.java | 1 +
.../baiyue/firstleg/dto/PriceBookDtos.java | 11 ++++-
.../entity/PriceBookVersionEntity.java | 3 ++
.../repository/PriceBookRepository.java | 11 +++++
.../service/PlatformPriceBookService.java | 22 ++++++++--
.../resources/db/phase2-inbound-schema.sql | 5 ++-
.../resources/openapi/fba-pricing-v1.yaml | 17 +++++++-
.../controller/PriceBookContractTest.java | 5 ++-
.../service/PlatformPriceBookServiceTest.java | 40 ++++++++++++++++++-
11 files changed, 130 insertions(+), 10 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 132ea8880..80d2d6bde 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -3872,7 +3872,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
- status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
+ status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
@@ -3883,6 +3883,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
+ voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
+ voided_at DATETIME(3) NULL COMMENT '作废时间',
+ void_reason VARCHAR(500) NULL COMMENT '作废原因',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
@@ -3893,6 +3896,10 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
KEY idx_price_book_source_hash (tenant_id, source_sha256)
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
+CALL add_column_if_missing('fl_price_book_version', 'voided_by', 'BIGINT UNSIGNED NULL COMMENT ''作废操作人ID'' AFTER superseded_at');
+CALL add_column_if_missing('fl_price_book_version', 'voided_at', 'DATETIME(3) NULL COMMENT ''作废时间'' AFTER voided_by');
+CALL add_column_if_missing('fl_price_book_version', 'void_reason', 'VARCHAR(500) NULL COMMENT ''作废原因'' AFTER voided_at');
+
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
@@ -14990,8 +14997,11 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'published
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'rate_count', '解析价格数');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_file_id', 'File服务原始文件ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_sha256', '原始文件SHA-256');
-CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败');
+CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'superseded_at', '被下一版本替换时间');
+CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_at', '作废时间');
+CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_by', '作废操作人ID');
+CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'void_reason', '作废原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'tenant_id', '平台公共价格作用域固定为0');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_at', '最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_by', '最后修改人ID');
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java
index af13b112d..78af65ba8 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java
@@ -75,6 +75,17 @@ public class PlatformPriceBookController {
return ApiResponse.success(service.publish(versionId, user.userId(), request));
}
+ @PostMapping("/{versionId}/void")
+ @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
+ @BusinessAudit(event = "FBA_PRICE_BOOK_VOID", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "作废待确认船期价格表")
+ public ApiResponse voidPending(
+ @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
+ @PathVariable long versionId,
+ @Valid @RequestBody PriceBookDtos.PriceBookVoidRequest request) {
+ FirstlegScopeGuard.platform(user);
+ return ApiResponse.success(service.voidPending(versionId, user.userId(), request));
+ }
+
@GetMapping("/{versionId}/source")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ResponseEntity source(
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java
index 717eefca6..c3bb8a276 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java
@@ -5,5 +5,6 @@ public enum PriceBookStatus {
PENDING_CONFIRMATION,
ACTIVE,
SUPERSEDED,
+ VOIDED,
FAILED
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java
index f0e0f4317..b4c4943da 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java
@@ -38,7 +38,16 @@ public final class PriceBookDtos {
LocalDateTime createdAt,
Long publishedBy,
LocalDateTime publishedAt,
- LocalDateTime supersededAt
+ LocalDateTime supersededAt,
+ Long voidedBy,
+ LocalDateTime voidedAt,
+ String voidReason
+ ) {}
+
+ public record PriceBookVoidRequest(
+ @NotBlank(message = "作废原因不能为空")
+ @Size(max = 500, message = "作废原因不能超过500个字符")
+ String reason
) {}
public record PriceBookVersionDetail(
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java
index d4fa1f579..69bddf846 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java
@@ -28,4 +28,7 @@ public class PriceBookVersionEntity extends BaseEntity {
private Long publishedBy;
private LocalDateTime publishedAt;
private LocalDateTime supersededAt;
+ private Long voidedBy;
+ private LocalDateTime voidedAt;
+ private String voidReason;
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java
index 7c03889ce..cc744793b 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java
@@ -156,4 +156,15 @@ public class PriceBookRepository {
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
+
+ public void voidPending(PriceBookVersionEntity value, LocalDateTime at, long operatorId, String reason) {
+ value.setStatus("VOIDED"); value.setVoidedAt(at); value.setVoidedBy(operatorId); value.setVoidReason(reason);
+ value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
+ rates.update(null, new LambdaUpdateWrapper()
+ .eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
+ .eq(CarrierFbaRateEntity::getStatus, "PENDING_CONFIRMATION")
+ .set(CarrierFbaRateEntity::getStatus, "VOIDED")
+ .set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
+ .set(CarrierFbaRateEntity::getUpdatedAt, at));
+ }
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java
index 9adcf4617..5c1fa8ce2 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java
@@ -158,6 +158,11 @@ public class PlatformPriceBookService {
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
+ List activeVersions = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
+ ? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId());
+ if (activeVersions.stream().anyMatch(active -> active.getVersionNo() >= version.getVersionNo())) {
+ throw new BizException(ResultCode.CONFLICT, "该待确认版本早于当前生效版本,请作废后重新上传");
+ }
List channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
MappingResult mapped = applyMappings(rates, confirmation);
@@ -174,14 +179,25 @@ public class PlatformPriceBookService {
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
- (version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
- ? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
+ activeVersions.stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
return detail(versionId);
}
+ @Transactional
+ public PriceBookDtos.PriceBookVersionDetail voidPending(long versionId, long operatorId,
+ PriceBookDtos.PriceBookVoidRequest request) {
+ PriceBookVersionEntity candidate = requireVersion(versionId);
+ if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
+ else repository.lockVersions(candidate.getTenantId());
+ PriceBookVersionEntity version = requirePendingConfirmation(versionId);
+ String reason = request.reason().trim();
+ repository.voidPending(version, LocalDateTime.now(), operatorId, reason);
+ return detail(versionId);
+ }
+
public ResponseEntity source(long versionId, long operatorId) {
PriceBookVersionEntity version = requireVersion(versionId);
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
@@ -562,7 +578,7 @@ public class PlatformPriceBookService {
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
- value.getPublishedAt(), value.getSupersededAt());
+ value.getPublishedAt(), value.getSupersededAt(), value.getVoidedBy(), value.getVoidedAt(), value.getVoidReason());
}
private static void requireScope(long customerId) {
diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql
index f26279406..9ae7b8d18 100644
--- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql
+++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql
@@ -558,7 +558,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
- status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
+ status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
@@ -569,6 +569,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
+ voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
+ voided_at DATETIME(3) NULL COMMENT '作废时间',
+ void_reason VARCHAR(500) NULL COMMENT '作废原因',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
index 580aaad9b..8ee870441 100644
--- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
+++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
@@ -112,6 +112,13 @@ paths:
parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
+ /api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/void:
+ post:
+ summary: 作废待确认船期价格表版本
+ description: 仅允许作废 PENDING_CONFIRMATION 版本;保留版本、原始文件和审计记录,作废后不可再确认生效。需要 firstleg:fba-pricing:rate:edit 权限。
+ parameters: [{$ref: '#/components/parameters/VersionId'}]
+ requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookVoidRequest'}}}}
+ responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source:
get:
summary: 下载船期价格表原始文件
@@ -528,7 +535,7 @@ components:
effectiveTo: {type: string, format: date-time, nullable: true}
PriceBookStatus:
type: string
- enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, FAILED]
+ enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, VOIDED, FAILED]
PriceModel:
type: string
enum: [FBA_WAREHOUSE_RATE, REGION_WEIGHT_TIER]
@@ -564,6 +571,9 @@ components:
publishedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
supersededAt: {type: string, format: date-time, nullable: true}
+ voidedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
+ voidedAt: {type: string, format: date-time, nullable: true}
+ voidReason: {type: string, maxLength: 500, nullable: true}
PriceBookVersionDetail:
type: object
required: [version, channels, rates, issues]
@@ -600,6 +610,11 @@ components:
type: array
maxItems: 2
items: {$ref: '#/components/schemas/PriceBookOverseasMapping'}
+ PriceBookVoidRequest:
+ type: object
+ required: [reason]
+ properties:
+ reason: {type: string, minLength: 1, maxLength: 500, description: 人工作废原因}
PriceBookOriginMapping:
type: object
required: [originWarehouseCode, warehouseId]
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java
index ae0a6d745..cc32c2703 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java
@@ -60,7 +60,7 @@ class PriceBookContractTest {
void keepsPricingTaxModeSeparate() {
assertThat(PriceBookStatus.values()).containsExactly(
PriceBookStatus.PENDING_CONFIRMATION, PriceBookStatus.ACTIVE,
- PriceBookStatus.SUPERSEDED, PriceBookStatus.FAILED);
+ PriceBookStatus.SUPERSEDED, PriceBookStatus.VOIDED, PriceBookStatus.FAILED);
assertThat(PricingTaxMode.values()).containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(PricingDestinationType.values()).containsExactly(PricingDestinationType.FBA, PricingDestinationType.OVERSEAS);
assertThat(PriceBookDtos.ParsedRate.class.getRecordComponents())
@@ -99,6 +99,7 @@ class PriceBookContractTest {
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:")
+ .contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/void:")
.contains("/api/merchant/v1/first-leg/pricing/channels:")
.contains("/api/merchant/v1/first-leg/pricing/estimate:")
.contains("PriceBookStatus")
@@ -114,6 +115,8 @@ class PriceBookContractTest {
.contains("PriceBookParseIssue")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
.contains("确认并立即生效")
+ .contains("PriceBookVoidRequest")
+ .contains("VOIDED")
.contains("SUPERSEDED");
String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml")
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java
index 5729b9a57..1e6ff9f5a 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java
@@ -137,7 +137,30 @@ class PlatformPriceBookServiceTest {
assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods())
.filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()))
.map(java.lang.reflect.Method::getName).toList())
- .containsOnly("list", "upload", "detail", "publish", "source");
+ .containsOnly("list", "upload", "detail", "publish", "voidPending", "source");
+ }
+
+ @Test
+ void voidsPendingVersionAndRetainsReasonForAudit() {
+ PriceBookVersionEntity pending = version(41L, PriceBookStatus.PENDING_CONFIRMATION);
+ when(repository.findVersion(41L)).thenReturn(pending);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ PriceBookVersionEntity value = invocation.getArgument(0);
+ value.setStatus(PriceBookStatus.VOIDED.name());
+ value.setVoidedBy(7L);
+ value.setVoidReason(invocation.getArgument(3));
+ return null;
+ }).when(repository).voidPending(any(), any(), any(Long.class), any());
+ when(repository.listChannels(41L)).thenReturn(List.of());
+ when(repository.listRates(41L)).thenReturn(List.of());
+
+ var result = service.voidPending(41L, 7L, new PriceBookDtos.PriceBookVoidRequest(" 上传错误 "));
+
+ assertThat(result.version().status()).isEqualTo(PriceBookStatus.VOIDED);
+ assertThat(result.version().voidedBy()).isEqualTo(7L);
+ assertThat(result.version().voidReason()).isEqualTo("上传错误");
+ verify(repository).lockPlatformVersions();
+ verify(repository).voidPending(any(), any(), any(Long.class), org.mockito.ArgumentMatchers.eq("上传错误"));
}
@Test
@@ -284,6 +307,21 @@ class PlatformPriceBookServiceTest {
verify(repository, never()).activate(any(), any(), any(Long.class));
}
+ @Test
+ void olderPendingVersionCannotReplaceNewerActiveVersion() {
+ PriceBookVersionEntity pending = version(5L, PriceBookStatus.PENDING_CONFIRMATION);
+ PriceBookVersionEntity active = version(8L, PriceBookStatus.ACTIVE);
+ when(repository.findVersion(5L)).thenReturn(pending);
+ when(repository.listActiveForUpdate()).thenReturn(List.of(active));
+
+ assertThatThrownBy(() -> service.publish(5L, 7L, confirmation("CN-YIWU-01", 501L)))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("早于当前生效版本");
+
+ verify(repository).lockPlatformVersions();
+ verify(repository, never()).activate(any(), any(), any(Long.class));
+ }
+
private static PriceBookVersionEntity version(long id, PriceBookStatus status) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setId(id);
From 2191bacbfe8b9247dbf9e835ff061e27996f94df Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 12 Sep 2026 12:27:56 +0800
Subject: [PATCH 59/63] fix qualification upload contract and test baseline
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 29 +++++++++++++++++++
.../file/client/IamQualificationClient.java | 4 +++
.../PlatformQualificationFileController.java | 3 +-
.../QualificationFileController.java | 2 +-
.../service/QualificationPackageService.java | 6 ++--
.../QualificationPackageServiceTest.java | 3 ++
.../initialization-baseline-contract.test.mjs | 13 +++++++++
...nt-qualification-fixture-contract.test.mjs | 2 ++
.../merchant-qualification-fixture.mjs | 5 ++--
9 files changed, 61 insertions(+), 6 deletions(-)
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 80d2d6bde..c9e2d8e6b 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -3896,9 +3896,38 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
KEY idx_price_book_source_hash (tenant_id, source_sha256)
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
+-- Warehouse 增量段已在自身结束时删除同名过程;Firstleg 价格版本的存量补列需在当前库重新定义。
+DROP PROCEDURE IF EXISTS add_column_if_missing;
+DELIMITER $$
+CREATE PROCEDURE add_column_if_missing(
+ IN p_table_name VARCHAR(64),
+ IN p_column_name VARCHAR(64),
+ IN p_column_definition TEXT
+)
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = p_table_name
+ AND column_name = p_column_name
+ ) THEN
+ SET @add_column_sql = CONCAT(
+ 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
+ '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'),
+ '` ', p_column_definition
+ );
+ PREPARE add_column_statement FROM @add_column_sql;
+ EXECUTE add_column_statement;
+ DEALLOCATE PREPARE add_column_statement;
+ END IF;
+END$$
+DELIMITER ;
+
CALL add_column_if_missing('fl_price_book_version', 'voided_by', 'BIGINT UNSIGNED NULL COMMENT ''作废操作人ID'' AFTER superseded_at');
CALL add_column_if_missing('fl_price_book_version', 'voided_at', 'DATETIME(3) NULL COMMENT ''作废时间'' AFTER voided_by');
CALL add_column_if_missing('fl_price_book_version', 'void_reason', 'VARCHAR(500) NULL COMMENT ''作废原因'' AFTER voided_at');
+DROP PROCEDURE IF EXISTS add_column_if_missing;
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
index a0c63a603..181269c14 100644
--- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
+++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
@@ -31,6 +31,7 @@ public interface IamQualificationClient {
record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
+ String originalFileName,
java.time.LocalDate validTo, boolean replace) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
@@ -38,12 +39,15 @@ public interface IamQualificationClient {
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
+ String originalFileName,
java.time.LocalDate validTo, boolean replace) { }
record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode,
String businessLicenseDocumentUrl,
+ String businessLicenseOriginalFileName,
java.time.LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
String legalPersonIdDocumentUrl,
+ String legalPersonIdOriginalFileName,
java.time.LocalDate legalPersonIdValidTo,
boolean replaceLegalPersonId) { }
record RequiredPackageResponse(Long businessLicenseQualificationId,
diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
index 44ed58565..e4694bede 100644
--- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
+++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
@@ -53,7 +53,8 @@ public class PlatformQualificationFileController {
try {
var response = iamClient.submitPlatform(internalToken,
new IamQualificationClient.PlatformQualificationRequest(
- tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
+ tenantId, qualificationType, certificateNo, stored.documentUrl(),
+ file.getOriginalFilename(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
index d47487852..fd8cf524b 100644
--- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
+++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
@@ -45,7 +45,7 @@ public class QualificationFileController {
try {
var response = iamClient.submit(internalToken,
new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
- certificateNo, stored.documentUrl(), validTo, replace));
+ certificateNo, stored.documentUrl(), file.getOriginalFilename(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java
index 63d2c00aa..5519973f0 100644
--- a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java
+++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java
@@ -45,8 +45,10 @@ public class QualificationPackageService {
String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl();
var request = new IamQualificationClient.RequiredPackageRequest(
- tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo,
- replaceBusinessLicense, legalPersonIdDocumentUrl, legalPersonIdValidTo, replaceLegalPersonId);
+ tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicense.getOriginalFilename(),
+ businessLicenseValidTo, replaceBusinessLicense, legalPersonIdDocumentUrl,
+ legalPersonId == null ? null : legalPersonId.getOriginalFilename(),
+ legalPersonIdValidTo, replaceLegalPersonId);
try {
var response = platform
? iamClient.submitPlatformRequiredPackage(internalToken, request)
diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java
index 2157f6b76..43d1b1944 100644
--- a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java
+++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java
@@ -57,6 +57,8 @@ class QualificationPackageServiceTest {
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode());
+ assertEquals("license.pdf", captor.getValue().businessLicenseOriginalFileName());
+ assertEquals("legal-person.png", captor.getValue().legalPersonIdOriginalFileName());
assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl());
assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl());
}
@@ -74,6 +76,7 @@ class QualificationPackageServiceTest {
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
assertNull(captor.getValue().legalPersonIdDocumentUrl());
+ assertNull(captor.getValue().legalPersonIdOriginalFileName());
assertNull(captor.getValue().legalPersonIdValidTo());
assertNull(result.legalPersonIdQualificationId());
assertNull(result.legalPersonIdDocumentUrl());
diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs
index d362a9e4c..b0efc204c 100644
--- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs
+++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs
@@ -67,6 +67,19 @@ test('国内统一初始化在注释门禁前创建订单申报快照表', () =>
}
})
+test('国内统一增量在 Firstleg 补列前重建已由 Warehouse 段删除的过程', () => {
+ const warehouseDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;',
+ sql.indexOf('CALL assert_platform_public_resource_scope();'))
+ const firstlegCall = sql.indexOf("CALL add_column_if_missing('fl_price_book_version', 'voided_by'")
+ const firstlegCreate = sql.lastIndexOf('CREATE PROCEDURE add_column_if_missing(', firstlegCall)
+ const firstlegDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;', firstlegCall)
+
+ assert.ok(warehouseDrop >= 0, 'Warehouse migration must clean up its helper procedure')
+ assert.ok(firstlegCreate > warehouseDrop, 'Firstleg must recreate the helper after the Warehouse cleanup')
+ assert.ok(firstlegCreate < firstlegCall, 'Firstleg helper must exist before the price-version ALTER calls')
+ assert.ok(firstlegDrop > firstlegCall, 'Firstleg must clean up the helper after its ALTER calls')
+})
+
test('海外统一初始化内联空库基线且不依赖模块 SQL', () => {
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/)
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)
diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
index b4d30a2b0..2b29d2014 100644
--- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
+++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
@@ -15,6 +15,8 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => {
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
+ assert.match(fixture, /required-package\?unifiedSocialCreditCode=\$\{encodeURIComponent\(unifiedSocialCreditCode\)\}/)
+ assert.doesNotMatch(fixture, /packageForm\.append\('unifiedSocialCreditCode'/)
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/)
assert.match(fixture, /status === 'READY'/)
diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs
index 168dac189..a2a5e94fd 100644
--- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs
+++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs
@@ -62,9 +62,10 @@ export async function run(context) {
const packageForm = new FormData()
packageForm.append('businessLicense', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `business-${suffix}.png`)
packageForm.append('legalPersonId', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `legal-person-${suffix}.png`)
- packageForm.append('unifiedSocialCreditCode', `91440300${suffix}`)
+ const unifiedSocialCreditCode = `91440300${suffix}`
const packageResponse = await context.requestWithBody('POST',
- `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package`, packageForm)
+ `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`,
+ packageForm)
context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse,
`tenantId=${tenantId}`, { deploymentBlocker: true })
From d33922ad3568564c844509541e4cb21c5abb7f39 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 12 Sep 2026 13:55:48 +0800
Subject: [PATCH 60/63] fix overseas return inventory flow
---
.../service/SecurityEventOutboxService.java | 2 +-
.../SecurityEventOutboxServiceTest.java | 5 +-
.../mapper/ReturnInventoryBalanceMapper.java | 7 +
.../service/ReturnInventoryService.java | 8 +-
.../service/ReturnInventoryServiceTest.java | 134 ++++++++++++++++++
5 files changed, 150 insertions(+), 6 deletions(-)
create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java
index 674748ef8..6d5266658 100644
--- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java
@@ -24,7 +24,7 @@ import java.util.stream.Collectors;
public class SecurityEventOutboxService {
private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:", "platform:overseas-business:",
- "platform:overseas-master-data:", "merchant:warehouse:",
+ "platform:overseas-master-data:", "merchant:warehouse:", "merchant:overseas-warehouse:",
"merchant:cross-border:", "platform:finance:", "lastleg:");
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java
index b66d458b7..b7e7f89c9 100644
--- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java
@@ -84,7 +84,7 @@ class SecurityEventOutboxServiceTest {
.thenReturn(List.of("PLATFORM_ADMIN"));
when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN"))
.thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view",
- "lastleg:orders:view"));
+ "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view"));
service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED");
@@ -96,7 +96,8 @@ class SecurityEventOutboxServiceTest {
envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class);
assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(payload.authorities()).containsExactlyInAnyOrder(
- "platform:overseas-warehouse:orders:view", "lastleg:orders:view");
+ "platform:overseas-warehouse:orders:view",
+ "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view");
assertThat(payload.authorities()).doesNotContain("platform:iam:users:view");
}
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java
index 18ef9bfa0..9aaeeb487 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java
@@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -20,6 +21,12 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper=0")
+ int adjustCartonQuantity(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
+ @Param("skuCode") String skuCode, @Param("cartons") long cartons);
+
@Select("")
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
index b6ff1ff3e..03e3ba1e6 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
@@ -65,7 +65,8 @@ public class ReturnInventoryService {
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
- carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
+ carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
+ carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -264,8 +265,9 @@ public class ReturnInventoryService {
// A SKU's carton count is the number of available physical cartons containing it:
// remove fully emptied source cartons, then add the one merged target carton.
long cartonDelta = 1L - emptiedBySku.getOrDefault(entry.getKey(), 0);
- balances.adjust(IdWorker.getId(), customerId, target.getOverseasWarehouseId(), entry.getKey(), productNames.get(entry.getKey()), 0,
- -entry.getValue(), cartonDelta);
+ if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1) {
+ throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
+ }
}
operation.setResultCartonId(targetId); operation.setUpdatedAt(now); operations.updateById(operation);
return new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), total, label(target));
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
new file mode 100644
index 000000000..dc5f6338b
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
@@ -0,0 +1,134 @@
+package com.baiyue.lastleg.returnreceipt.service;
+
+import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
+import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
+import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
+import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
+import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
+import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryQueryMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
+import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ReturnInventoryServiceTest {
+ @Mock ReturnInventoryCartonMapper cartons;
+ @Mock ReturnInventoryCartonItemMapper cartonItems;
+ @Mock ReturnInventoryBalanceMapper balances;
+ @Mock ReturnInventoryOperationMapper operations;
+ @Mock ReturnInventoryQueryMapper queries;
+ @Mock ReturnInventoryMovementMapper movements;
+ @Mock LastlegOrderNoGenerator numbers;
+ @Mock ReturnOrderMapper orders;
+ @Mock ReturnReceiptMapper receipts;
+ private ReturnInventoryService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new ReturnInventoryService();
+ ReflectionTestUtils.setField(service, "cartons", cartons);
+ ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
+ ReflectionTestUtils.setField(service, "balances", balances);
+ ReflectionTestUtils.setField(service, "operations", operations);
+ ReflectionTestUtils.setField(service, "queries", queries);
+ ReflectionTestUtils.setField(service, "movements", movements);
+ ReflectionTestUtils.setField(service, "numbers", numbers);
+ ReflectionTestUtils.setField(service, "orders", orders);
+ ReflectionTestUtils.setField(service, "receiptMapper", receipts);
+ }
+
+ @Test
+ void receiptCartonKeepsTheSourceReturnNumber() {
+ ReturnOrder order = completedOrder();
+ ReturnReceipt receipt = new ReturnReceipt();
+ receipt.setId(21L);
+ receipt.setReceiptBatchNo("RB-1");
+ when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
+
+ service.createReceiptCarton(order, receipt, null,
+ List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2)));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
+ verify(cartons).insert(captor.capture());
+ assertThat(captor.getValue().getSourceReturnNo()).isEqualTo("RT-1");
+ }
+
+ @Test
+ void mergeMovesItemsBetweenCartonsWithoutReducingAvailableQuantity() {
+ ReturnOrder order = completedOrder();
+ ReturnReceipt receipt = new ReturnReceipt();
+ receipt.setId(21L);
+ ReturnInventoryCarton source = new ReturnInventoryCarton();
+ source.setId(31L);
+ source.setCustomerId("10001");
+ source.setOverseasWarehouseId("42");
+ source.setCartonMark("RC-SOURCE");
+ source.setStatus(ReturnInventoryService.AVAILABLE);
+ source.setSkuTypes(2);
+ source.setTotalQuantity(4L);
+ ReturnInventoryCartonItem skuA = item(41L, "SKU-A", 2L);
+ ReturnInventoryCartonItem skuB = item(42L, "SKU-B", 2L);
+
+ when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(order));
+ when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
+ when(cartons.findReceipt(21L)).thenReturn(source);
+ when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
+ when(cartonItems.listCarton(31L)).thenReturn(List.of(skuA, skuB));
+ when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
+ when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
+
+ service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
+ new ReturnInventoryRequests.Merge(List.of("RT-1"), List.of(
+ new ReturnInventoryRequests.ValidItem("SKU-A", 1),
+ new ReturnInventoryRequests.ValidItem("SKU-B", 1)),
+ BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "merge-key"));
+
+ verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
+ verify(balances).adjustCartonQuantity("10001", "42", "SKU-B", 1L);
+ assertThat(source.getTotalQuantity()).isEqualTo(2L);
+ assertThat(source.getStatus()).isEqualTo(ReturnInventoryService.AVAILABLE);
+ }
+
+ private ReturnOrder completedOrder() {
+ ReturnOrder order = new ReturnOrder();
+ order.setId(11L);
+ order.setCustomerId("10001");
+ order.setReturnNo("RT-1");
+ order.setOverseasWarehouseId("42");
+ order.setStatus("COMPLETED");
+ return order;
+ }
+
+ private ReturnInventoryCartonItem item(long id, String sku, long quantity) {
+ ReturnInventoryCartonItem item = new ReturnInventoryCartonItem();
+ item.setId(id);
+ item.setCartonId(31L);
+ item.setCustomerId("10001");
+ item.setSkuCode(sku);
+ item.setProductNameZh(sku);
+ item.setQuantity(quantity);
+ return item;
+ }
+}
From e467d60e8f4639f18d676d8910d633710c957212 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 12 Sep 2026 16:14:50 +0800
Subject: [PATCH 61/63] fix(lastleg): allow edits with historical procurement
warehouse
---
.../service/OverseasProcurementService.java | 3 +++
.../OverseasProcurementServiceTest.java | 21 +++++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
index 360e9832b..dc93177b3 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
@@ -417,6 +417,9 @@ public class OverseasProcurementService implements WalletResultHandler {
} catch (NumberFormatException exception) {
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
}
+ // Keep the warehouse snapshot already frozen on a historical order. A later master-data
+ // deactivation must not block unrelated edits such as recording outbound details.
+ if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return;
var option = warehouseDirectory.required(numericId);
order.setPurchaseWarehouseId(option.warehouseId());
order.setPurchaseWarehouseName(option.warehouseName());
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
index 6f6f69118..dc87b1865 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java
@@ -177,6 +177,27 @@ class OverseasProcurementServiceTest {
assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName());
}
+ @Test
+ void unchangedHistoricalWarehouseDoesNotBlockUnrelatedEditsAfterDeactivation() {
+ OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0);
+ order.setPurchaseWarehouseId("10");
+ order.setPurchaseWarehouseName("历史采购仓");
+ when(orders.lockNo("100", "HC1")).thenReturn(order);
+ when(orders.findNo("100", "HC1")).thenReturn(order);
+ when(histories.listOrder("100", 1L)).thenReturn(List.of());
+ ObjectMapper mapper = new ObjectMapper();
+ var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
+ null, mapper.getNodeFactory().textNode("10"), null,
+ null, null, null, null, null, mapper.getNodeFactory().textNode("更新出库信息"));
+
+ service.update("100", "HC1", "7", request);
+
+ assertEquals("10", order.getPurchaseWarehouseId());
+ assertEquals("历史采购仓", order.getPurchaseWarehouseName());
+ verify(warehouseDirectory, never()).required(10L);
+ verify(orders).updateById(order);
+ }
+
@Test
void customerOptionsAreDeduplicatedSnapshotsFromProcurementOrders() {
OverseasProcurementOrder option = new OverseasProcurementOrder();
From f26e4b29722f1391409365d1c9cbeeee704e9653 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 12 Sep 2026 16:16:38 +0800
Subject: [PATCH 62/63] fix(lastleg): trace return carton sources and generate
scannable labels
---
deploy/sql/海外区域统一增量-MySQL8.sql | 1 +
lastleg-service/AGENTS.md | 1 +
lastleg-service/pom.xml | 1 +
.../MerchantReturnInventoryController.java | 7 ++++
.../dto/ReturnInventoryResponses.java | 5 +++
.../mapper/ReturnInventoryCartonMapper.java | 15 +++++++
.../service/ReturnCartonBarcode.java | 26 ++++++++++++
.../service/ReturnInventoryService.java | 28 ++++++++++---
.../src/main/resources/db/lastleg.sql | 1 +
.../openapi/overseas-business-v1.yaml | 15 +++++++
.../service/ReturnCartonBarcodeTest.java | 36 ++++++++++++++++
.../service/ReturnCartonQueryTest.java | 32 +++++++++++++++
.../service/ReturnInventoryServiceTest.java | 41 +++++++++++++++++++
13 files changed, 204 insertions(+), 5 deletions(-)
create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java
create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java
create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index 0032dc897..0b8f14497 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -2530,6 +2530,7 @@ PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inven
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
+ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index 7cf2c759c..e215e0bd1 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -15,6 +15,7 @@
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
+- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml
index 01edad467..1314eb0d7 100644
--- a/lastleg-service/pom.xml
+++ b/lastleg-service/pom.xml
@@ -5,6 +5,7 @@
com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
lastleg-service
+ com.google.zxingcore3.5.3
com.baiyue.sharedcommon-event-contract${project.version}
com.baiyue.sharedbaiyue-common-openapi${project.version}
com.baomidoumybatis-plus-spring-boot3-starter
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java
index 144050b21..0713b49bc 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java
@@ -37,6 +37,13 @@ public class MerchantReturnInventoryController {
return ApiResponse.success(service.summaryPage(customer(user), request));
}
+ @GetMapping("/cartons")
+ @PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
+ public ApiResponse> cartons(@AuthenticationPrincipal AuthenticatedUser user,
+ @Valid ReturnInventoryRequests.Query request) {
+ return ApiResponse.success(service.cartonPage(customer(user), request));
+ }
+
@GetMapping("/cartons/{cartonMark}")
@PreAuthorize("hasAuthority('" + LIST_PERMISSION + "')")
public ApiResponse carton(@AuthenticationPrincipal AuthenticatedUser user,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java
index deb12c93f..5130349d8 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java
@@ -7,6 +7,11 @@ import java.util.List;
public final class ReturnInventoryResponses {
private ReturnInventoryResponses() { }
+ public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo,
+ String sourceReturnNo, String status, int skuTypes, long totalQuantity,
+ BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
+ LocalDateTime createdAt) { }
+
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java
index d2e200ab5..c2ddec712 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java
@@ -9,6 +9,21 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper {
+ String MERCHANT_FILTER = " FROM ll_return_inventory_carton c WHERE c.customer_id=#{customerId} " +
+ "AND c.overseas_warehouse_id=#{query.overseasWarehouseId} " +
+ "AND c.carton_mark LIKE CONCAT('%',#{query.cartonMark},'%') " +
+ "AND c.source_return_no LIKE CONCAT('%',#{query.returnNo},'%') " +
+ "AND c.status=#{query.status} " +
+ "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{query.skuCode},'%')) ";
+
+ @Select("")
+ long merchantCount(@Param("customerId") String customerId,
+ @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query);
+
+ @Select("")
+ List merchantPage(@Param("customerId") String customerId,
+ @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset);
+
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500")
List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java
new file mode 100644
index 000000000..7811d8d63
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java
@@ -0,0 +1,26 @@
+package com.baiyue.lastleg.returnreceipt.service;
+
+import com.google.zxing.oned.Code128Writer;
+
+/** Inline vector bars remain printable even when browser background printing is disabled. */
+final class ReturnCartonBarcode {
+ private static final int QUIET_ZONE = 10;
+
+ private ReturnCartonBarcode() { }
+
+ static String svg(String cartonMark) {
+ boolean[] modules = new Code128Writer().encode(cartonMark);
+ int width = modules.length + QUIET_ZONE * 2;
+ StringBuilder svg = new StringBuilder("").toString();
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
index 03e3ba1e6..4daa6e0b5 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java
@@ -91,6 +91,19 @@ public class ReturnInventoryService {
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
+ @Transactional(readOnly = true)
+ public PageResult cartonPage(String customerId, ReturnInventoryRequests.Query request) {
+ required(customerId, "客户身份不能为空");
+ long total = cartons.merchantCount(customerId, request);
+ List records = total == 0 ? List.of() :
+ cartons.merchantPage(customerId, request, (request.page() - 1) * request.size()).stream()
+ .map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(),
+ carton.getSourceBatchNo(), carton.getSourceReturnNo(), carton.getStatus(),
+ carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()),
+ carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
+ return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
+ }
+
@Transactional(readOnly = true)
public PageResult summaryPage(String customerId, ReturnInventoryRequests.SummaryQuery request) {
String warehouseId = text(request.overseasWarehouseId());
@@ -230,6 +243,8 @@ public class ReturnInventoryService {
}
Map remaining = new LinkedHashMap<>(requested);
Map emptiedBySku = new LinkedHashMap<>();
+ LinkedHashSet consumedBatchNos = new LinkedHashSet<>();
+ LinkedHashSet consumedReturnNos = new LinkedHashSet<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List sourceItems = cartonItems.listCarton(source.getId());
@@ -238,6 +253,8 @@ public class ReturnInventoryService {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
long take = Math.min(value(remaining.get(sku)), value(item.getQuantity()));
if (take > 0) {
+ consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
+ consumedReturnNos.add(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱"));
remaining.put(sku, remaining.get(sku) - take); total = Math.addExact(total, take);
productNames.putIfAbsent(sku, item.getProductNameZh());
item.setQuantity(item.getQuantity() - take);
@@ -256,7 +273,7 @@ public class ReturnInventoryService {
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
- target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo("MERGED"); target.setSourceReturnNo(String.join(",", returnNos)); target.setStatus(AVAILABLE);
+ target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
for (Map.Entry entry : requested.entrySet()) {
ReturnInventoryCartonItem targetItem = new ReturnInventoryCartonItem();
@@ -312,13 +329,14 @@ public class ReturnInventoryService {
for (ReturnInventoryResponses.CartonItem item : items) rows.append("| ").append(escape(item.skuCode())).append(" | ")
.append(escape(item.productNameZh())).append(" | ").append(item.quantity()).append(" |
");
return "退货箱唛BAIYUE OVERSEAS WAREHOUSE
RETURN CARTON LABEL / 退货箱唛
"
- + escape(carton.getCartonMark()) + "
"
- + "
仓库:" + escape(carton.getOverseasWarehouseId()) + "
客户:" + escape(carton.getCustomerId())
- + "
箱规:" + escape(String.valueOf(carton.getLength())) + " × " + escape(String.valueOf(carton.getWidth())) + " × " + escape(String.valueOf(carton.getHeight())) + " cm / " + escape(String.valueOf(carton.getWeight())) + " kg"
+ + "@page{size:100mm 150mm;margin:5mm}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{box-sizing:border-box;width:90mm;border:2px solid #111;padding:3mm;min-height:138mm}.brand{font-size:12px;font-weight:700}.title{font-size:16px;font-weight:700;margin:3mm 0}.mark{font:700 16px monospace;word-break:break-all}.meta{font-size:11px;margin:3mm 0;border-top:1px solid #111;border-bottom:1px solid #111;padding:2mm 0;overflow-wrap:anywhere}.barcode{margin:3mm 0}table{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}th,td{border:1px solid #111;padding:1mm;text-align:left;overflow-wrap:anywhere}tr{break-inside:avoid}.qty{text-align:right;font-weight:700}.total{font-size:14px;font-weight:700;text-align:right;margin-top:3mm}
BAIYUE OVERSEAS WAREHOUSE
RETURN CARTON LABEL / 退货箱唛
"
+ + escape(carton.getCartonMark()) + "
" + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
"
+ + "
来源退货单:" + escape(carton.getSourceReturnNo())
+ + "
箱规:" + dimension(carton.getLength()) + " × " + dimension(carton.getWidth()) + " × " + dimension(carton.getHeight()) + " cm / " + dimension(carton.getWeight()) + " kg"
+ "
签收批次:" + escape(carton.getSourceBatchNo()) + "
合计:" + carton.getTotalQuantity() + " 件 / " + carton.getSkuTypes() + " 种 SKU
";
}
+ private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); }
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(String.valueOf(request).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException("无法生成请求哈希", exception); } }
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index 3718bfa44..e9a5afbcb 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -2437,6 +2437,7 @@ PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inven
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
+ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
index 2038a62d9..afc5a08c1 100644
--- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
@@ -1393,6 +1393,21 @@ paths:
- { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } }
- { name: skuCode, in: query, schema: { type: string, maxLength: 128 } }
responses: { '200': { description: 当前客户 SKU 汇总库存分页 } }
+ /api/merchant/v1/last-leg/return-inventory/cartons:
+ get:
+ summary: 客户退货箱号分页
+ description: 需要 merchant:overseas-warehouse:return-inventory:list。客户范围仅取登录身份且精确匹配;每个实物箱一条记录,SKU 筛选使用存在性条件,不重复箱号或放大总箱数。
+ security: [{ bearerAuth: [] }]
+ parameters:
+ - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
+ - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
+ - { name: overseasWarehouseId, in: query, schema: { type: string } }
+ - { name: skuCode, in: query, schema: { type: string } }
+ - { name: cartonMark, in: query, schema: { type: string } }
+ - { name: returnNo, in: query, schema: { type: string } }
+ - { name: status, in: query, schema: { type: string } }
+ responses:
+ '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 }
/api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 客户查询退货箱明细
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java
new file mode 100644
index 000000000..6671baf2b
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java
@@ -0,0 +1,36 @@
+package com.baiyue.lastleg.returnreceipt.service;
+
+import com.google.zxing.common.BitArray;
+import com.google.zxing.oned.Code128Reader;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReturnCartonBarcodeTest {
+ @ParameterizedTest
+ @ValueSource(strings = {"RC-WH-20260912-480898", "RC-WH-20260912-274178", "RC-1", "RC-MERGED"})
+ void decodesTheActualRenderedSvgBarsToTheExactCartonMark(String mark) throws Exception {
+ String svg = ReturnCartonBarcode.svg(mark);
+ var viewBox = Pattern.compile("viewBox=\"0 0 (\\d+) 50\"").matcher(svg);
+ assertThat(viewBox.find()).isTrue();
+ int width = Integer.parseInt(viewBox.group(1));
+ BitArray scanline = new BitArray(width);
+ var bars = Pattern.compile("
").matcher(svg);
+ int firstBar = width;
+ int lastBar = 0;
+ while (bars.find()) {
+ int start = Integer.parseInt(bars.group(1));
+ int end = start + Integer.parseInt(bars.group(2));
+ firstBar = Math.min(firstBar, start);
+ lastBar = Math.max(lastBar, end);
+ scanline.setRange(start, end);
+ }
+ assertThat(firstBar).isGreaterThanOrEqualTo(10);
+ assertThat(width - lastBar).isGreaterThanOrEqualTo(10);
+ assertThat(new Code128Reader().decodeRow(0, scanline, null).getText()).isEqualTo(mark);
+ assertThat(svg).doesNotContain("background", "repeating-linear-gradient");
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java
new file mode 100644
index 000000000..491b1b4c7
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java
@@ -0,0 +1,32 @@
+package com.baiyue.lastleg.returnreceipt.service;
+
+import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReturnCartonQueryTest {
+ @Test
+ void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
+ var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
+ for (String name : new String[]{"merchantCount", "merchantPage"}) {
+ var method = java.util.Arrays.stream(ReturnInventoryCartonMapper.class.getMethods())
+ .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
+ String script = String.join(" ", method.getAnnotation(Select.class).value());
+ var source = new XMLLanguageDriver().createSqlSource(new Configuration(), script, Map.class);
+ var bound = source.getBoundSql(Map.of("customerId", "10001", "query", query, "offset", 1L));
+ String sql = bound.getSql().replaceAll("\\s+", " ");
+ assertThat(sql).contains("WHERE c.customer_id=?", "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item");
+ assertThat(sql).doesNotContain("customer_id LIKE", " JOIN ");
+ assertThat(bound.getParameterMappings().get(0).getProperty()).isEqualTo("customerId");
+ if (name.equals("merchantPage")) assertThat(sql).contains("ORDER BY c.created_at DESC,c.id DESC LIMIT ?,?");
+ else assertThat(sql).startsWith("SELECT COUNT(*) FROM ll_return_inventory_carton");
+ }
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
index dc5f6338b..b156f9237 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java
@@ -59,6 +59,41 @@ class ReturnInventoryServiceTest {
ReflectionTestUtils.setField(service, "receiptMapper", receipts);
}
+ @Test
+ void printableLabelUsesBusinessNumbersAndShowsMissingDimensionsWithoutNullOrInternalIds() {
+ ReturnInventoryCarton carton = new ReturnInventoryCarton();
+ carton.setId(31L);
+ carton.setCartonMark("RC-WH-20260912-480898");
+ carton.setCustomerId("2098676663424974850");
+ carton.setOverseasWarehouseId("193000000000200003");
+ carton.setSourceReturnNo("RT-1");
+ carton.setSourceBatchNo("RB-1,RB-2");
+ carton.setTotalQuantity(2L);
+ carton.setSkuTypes(1);
+ when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton);
+ when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
+ String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml();
+ assertThat(html).contains("