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 mapper = invocation.getArgument(1); - ResultSet rs = mock(ResultSet.class); - when(rs.getString("currency_code")).thenReturn("CNY"); - when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00")); - when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO); - when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00")); - when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO); - when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO); - when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO); - when(rs.getInt("version")).thenReturn(2); - when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00")); - return mapper.mapRow(rs, 0); - }); + void deductionMayMakeActualBalanceNegative() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, null, 2); - assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1", - new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(), - "BANK_TRANSFER", "REF-1", "人工扣款", 2))) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT"); - verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class)); + WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01")); + + assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01"); + assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01"); + assertThat(deducted.creditActive()).isFalse(); } @Test - void configuredCreditLimitIsUsedForAFirstAccountRead() { + void aMissingAccountNeverReceivesPermanentDefaultCredit() { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); - var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L); + var account = service(jdbc, "x", "y").account(9L); - assertThat(account.creditLimit()).isEqualByComparingTo("8000.00"); - assertThat(account.availableCredit()).isEqualByComparingTo("8000.00"); - assertThat(account.availableBalance()).isEqualByComparingTo("8000.00"); + assertThat(account.creditLimit()).isZero(); + assertThat(account.availableCredit()).isZero(); + assertThat(account.availableBalance()).isZero(); + assertThat(account.creditExpiresOn()).isNull(); verify(jdbc, never()).update(anyString(), any(Object[].class)); } @@ -102,7 +84,7 @@ class FinanceWalletBehaviorTest { void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO, - new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3); + new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3); WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00")); assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00"); @@ -120,7 +102,7 @@ class FinanceWalletBehaviorTest { void deductionNeverConsumesCashReservedByAnActiveHold() { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"), - new BigDecimal("80.00"), BigDecimal.ZERO, new BigDecimal("20.00"), 4); + new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4); WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00")); @@ -135,37 +117,40 @@ class FinanceWalletBehaviorTest { @Test void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); - WalletAccount initial = service.accountState(new BigDecimal("30.00"), new BigDecimal("25.00"), - new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), 5); + WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"), + new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), + LocalDate.now().plusDays(7), 5); WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00")); - assertThat(recharged.actualBalance()).isEqualByComparingTo("40.00"); + assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00"); assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00"); assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00"); assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00"); assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00"); - assertThat(recharged.availableBalance()).isEqualByComparingTo("115.00"); + assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00"); } @Test void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); - assertThatThrownBy(() -> service.accountState(new BigDecimal("10.00"), new BigDecimal("25.00"), - new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), 1)) + assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"), + new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), + LocalDate.now().plusDays(7), 1)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409"); } @Test - void accountStateRejectsFrozenCashAboveActualCash() { + void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); - assertThatThrownBy(() -> service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"), - new BigDecimal("20.00"), BigDecimal.ZERO, BigDecimal.ZERO, 1)) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409"); + WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"), + new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1); + + assertThat(account.availableBalance()).isEqualByComparingTo("19.99"); + assertThat(account.creditActive()).isTrue(); } @Test @@ -173,32 +158,27 @@ class FinanceWalletBehaviorTest { FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"), - new BigDecimal("50.00"), BigDecimal.ZERO, new BigDecimal("10.01"), 1)) + new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409"); } @Test - void invalidDefaultCreditConfigurationFailsClosed() { - assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("-0.01"))) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); - assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1.001"))) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); - assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1000000000000.00"))) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); + void expiredCreditNoLongerContributesToAvailableBalance() { + FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y"); + + WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO, + new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO, + LocalDate.now().minusDays(1), 1); + + assertThat(account.creditActive()).isFalse(); + assertThat(account.availableCredit()).isZero(); + assertThat(account.availableBalance()).isEqualByComparingTo("-20.00"); } private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) { - return service(jdbc, wechat, alipay, BigDecimal.ZERO); - } - - private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay, - BigDecimal defaultCreditLimit) { return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(), mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay, - defaultCreditLimit, "test", "test"); + "test", "test"); } } 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 77dc5e25f..4e08fb76b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -60,10 +60,22 @@ public interface DropshipPalletizationMapper { @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo); @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId); - @Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") + @Select(""" + SELECT p.* FROM fl_dropship_pallet p + WHERE p.id=#{palletId} AND p.warehouse_id=#{warehouseId} + AND (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})) + """) PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId); - @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List pallets(@Param("taskId") long taskId); - @Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id") + @Select(""" + SELECT DISTINCT p.* FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE c.task_id=#{taskId} ORDER BY p.id + """) List pallets(@Param("taskId") long taskId); + @Select(""" + SELECT DISTINCT p.* FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE 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") String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId); @@ -123,46 +135,66 @@ public interface DropshipPalletizationMapper { @Update(""" UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId}, completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion} + WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion} AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) - AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""") + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId}) + AND NOT EXISTS( + SELECT 1 FROM fl_dropship_pallet_task t + WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=t.id) + AND (t.counted_cartons<>t.total_cartons OR t.allocated_cartons<>t.total_cartons + OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton pending WHERE pending.task_id=t.id AND pending.pallet_id IS NULL)) + )""") int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion, - @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId); + @Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select(""" + SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.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(*) FROM fl_dropship_pallet p - WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1 + 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 + WHERE c.tenant_id=o.tenant_id AND c.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1 WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE' AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""") int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId); - @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List cartons(@Param("palletId") long palletId); + @Select(""" + SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c + LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId} ORDER BY o.inbound_code,c.sku_code,c.inbound_detail_id,c.id + """) List cartons(@Param("palletId") long palletId); @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List unboundCartons(@Param("taskId") long taskId); @Select(""" - SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, - COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} - GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""") + SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg, + COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId}""") PalletMetrics metrics(@Param("palletId") long palletId); @Select(""" SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count, COUNT(DISTINCT c.sku_code) sku_count, - COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, - COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id - WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED' + COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg, + COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm + FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton own WHERE own.pallet_id=p.id AND own.inbound_id=#{inboundId}) + AND p.pallet_status='SEALED' AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0) - GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id + GROUP BY p.id,p.pallet_no ORDER BY p.id """) List palletCandidates(@Param("inboundId") long inboundId); @Select(""" - SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no, + SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no, o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id, o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name, c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count, @@ -170,9 +202,9 @@ public interface DropshipPalletizationMapper { COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg, COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg, COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} - GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code, + FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId} + GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code, o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name, o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY c.inbound_detail_id @@ -181,7 +213,7 @@ public interface DropshipPalletizationMapper { @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; } @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; } - @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } + @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 9d4493422..947107039 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -85,7 +85,8 @@ public interface LoadingCompletedEventMapper { pallet.pallet_no AS pallet_code,NULL AS allocation_no, COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count, carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code, - item.product_name_zh,item.package_count,item.product_quantity,carton.box_code + item.product_name_zh,item.package_count,item.product_quantity,carton.box_code, + carton.actual_sku_quantity FROM fl_dropship_pallet pallet JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} @@ -98,15 +99,17 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p - JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id - AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id + AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' """) int workPalletCount(@Param("planId") long planId); @Select(""" SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p - JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id - AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id + AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' """) int openWorkPalletCount(@Param("planId") long planId); @@ -138,6 +141,6 @@ public interface LoadingCompletedEventMapper { private Boolean actualBinding; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; - private Integer packageCount; private Integer productQuantity; private String boxCode; + private Integer packageCount; private Integer productQuantity; private Integer actualSkuQuantity; private String boxCode; } } 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 513ab7594..f8efa803c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -143,16 +143,18 @@ public class DropshipPalletizationService { public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){ TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); PalletRow replay=mapper.palletBySealOperation(operation(request.operationId())); - if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } + if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } requireOpenPallet(taskId,palletId,request.warehouseId()); if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托"); - if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1) + if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1) throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); - TaskRow refreshed=requireTask(taskId,request.warehouseId()); - if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) && - Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){ - if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1) - throw conflict("组托完成状态已变化,请刷新后重试"); + for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){ + TaskRow participating=requireTask(participatingTaskId,request.warehouseId()); + if(Objects.equals(participating.getCountedCartons(),participating.getTotalCartons()) && + Objects.equals(participating.getAllocatedCartons(),participating.getTotalCartons()) && mapper.openPalletCount(participatingTaskId)==0){ + if(mapper.completeTask(participatingTaskId)!=1 || mapper.completeInbound(participatingTaskId,participating.getTenantId(),participating.getInboundId(),operatorId)!=1) + throw conflict("组托完成状态已变化,请刷新后重试"); + } } return view(requireTask(taskId,request.warehouseId())); } @@ -166,14 +168,14 @@ public class DropshipPalletizationService { private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){ PalletRow existing=mapper.palletByNo(palletNo); if(existing!=null){ - if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用"); + if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码属于其他仓库"); if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); return existing; } try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); } catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); } existing=mapper.palletByNo(palletNo); - if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用"); + if(existing==null || !Objects.equals(existing.getWarehouseId(),task.getWarehouseId()) || !"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘码已被其他仓库占用或已经封托"); return existing; } @@ -192,15 +194,15 @@ public class DropshipPalletizationService { private Pallet palletView(PalletRow row,String inboundCode){ List cartons=mapper.cartons(row.getId()); Map> groups=new LinkedHashMap<>(); - for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); - List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode, + for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundId()+"|"+c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); + List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),first.getInboundCode(), first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(), values.stream().map(CartonRow::getBoxCode).toList());}).toList(); int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count(); PalletMetrics metrics=mapper.metrics(row.getId()); return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies, metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items, - cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt()); + cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt()); } private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 1934f8a59..b8765e06b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -67,12 +67,14 @@ public class LoadingCompletedOutboxService { palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); Map> cartonMarks = cartonLabelPools(rows); List palletRows = palletRows(planId); + Map palletManifests = palletManifests(palletRows); List standardLabels = palletLabelMapper.labels(planId); return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(), palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), - standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks)) + standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks, + palletManifests)) .toList(); } @@ -98,14 +100,16 @@ public class LoadingCompletedOutboxService { private EventEnvelope envelope(long accountId, List rows, List palletRows, List standardLabels, - Map> cartonMarks) { + Map> cartonMarks, + Map palletManifests) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() .map(value -> order(value, palletRows.stream() .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), - standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); + standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), + cartonMarks, palletManifests)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), @@ -123,7 +127,8 @@ public class LoadingCompletedOutboxService { private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, List standardLabels, - Map> cartonMarks) { + Map> cartonMarks, + Map palletManifests) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -138,7 +143,7 @@ public class LoadingCompletedOutboxService { String fulfillmentType = fulfillmentType(sorted); String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List pallets = dropship(fulfillmentType) - ? palletSnapshots(sorted, palletRows) : standardLabels.stream() + ? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream() .map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); @@ -257,10 +262,11 @@ public class LoadingCompletedOutboxService { private record LabelPoolKey(long accountId, long inboundId) { } private record InboundPalletKey(long accountId, long inboundId) { } - private List palletSnapshots(List values, List source) { + private List palletSnapshots(List values, List source, + Map manifests) { if (source.isEmpty()) return List.of(); if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) { - return actualPalletSnapshots(values, source); + return actualPalletSnapshots(values, source, manifests); } Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), @@ -314,7 +320,7 @@ public class LoadingCompletedOutboxService { /** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */ private List actualPalletSnapshots( - List values, List source) { + List values, List source, Map manifests) { Map details = values.stream().collect(java.util.stream.Collectors.toMap( Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new)); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( @@ -334,14 +340,17 @@ public class LoadingCompletedOutboxService { if (marks.size() != detailRows.getValue().size()) { throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛"); } - int quantity = detail.getProductQuantity() == null ? marks.size() - : (int) Math.round((double) detail.getProductQuantity() * marks.size() - / Math.max(1, detail.getPackageCount())); + int quantity = detailRows.getValue().stream().map(PalletRow::getActualSkuQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量"); skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(), detail.getProductNameZh(), marks.size(), quantity, marks)); actualPackages += marks.size(); } - result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus)); + PalletManifest manifest = manifests.get(pallet.getKey()); + if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要"); + result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus, + manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash())); } if (actualPackages != sumInt(values, Row::getPackageCount)) { throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致"); @@ -349,12 +358,32 @@ public class LoadingCompletedOutboxService { return result; } + private Map palletManifests(List rows) { + Map> grouped = rows.stream().filter(row -> Boolean.TRUE.equals(row.getActualBinding())) + .collect(java.util.stream.Collectors.groupingBy(PalletRow::getPalletCode, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + Map result = new LinkedHashMap<>(); + grouped.forEach((palletNo, values) -> { + List sorted = values.stream().sorted(Comparator + .comparing(PalletRow::getAccountId).thenComparing(PalletRow::getSourceInboundId) + .thenComparing(PalletRow::getSourceInboundDetailId).thenComparing(PalletRow::getBoxCode)).toList(); + String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId() + + "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode() + + "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n")); + int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count(); + result.put(palletNo, new PalletManifest(1, sorted.size(), fragments, + EventPayloadHash.sha256(canonical.getBytes(StandardCharsets.UTF_8)))); + }); + return result; + } + private static boolean dropship(String value) { return "ONE_CLICK_DROPSHIP".equals(value); } private record DetailSnapshot(Long detailId, String skuCode, String productName, Integer packageCount, Integer productQuantity, List marks) {} + private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {} private void insert(EventEnvelope envelope) { InboundOutboxEntity value = new InboundOutboxEntity(); 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 8245dbde0..5d20f8dcf 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -150,11 +150,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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 76adbe620..07ce90b3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -290,8 +290,8 @@ class LoadingCompletedOutboxServiceTest { if (actualBinding) { when(snapshots.workPalletCount(90L)).thenReturn(1); when(snapshots.actualPalletRows(90L)).thenReturn(List.of( - actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102"), - actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101"))); + actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102",20), + actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101",10))); } else { when(snapshots.palletRows(90L)).thenReturn(List.of( pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"), @@ -348,10 +348,10 @@ class LoadingCompletedOutboxServiceTest { when(snapshots.rows(90L)).thenReturn(List.of(first, second)); when(snapshots.workPalletCount(90L)).thenReturn(2); when(snapshots.actualPalletRows(90L)).thenReturn(List.of( - actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2"), - actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1"), - actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1"), - actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2"))); + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2",10), + actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1",5), + actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1",10), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2",5))); service.publish(90L); @@ -422,9 +422,10 @@ class LoadingCompletedOutboxServiceTest { } private PalletRow actualPallet(long account,long inbound,String palletNo,long detail, - String sku,String boxCode) { + String sku,String boxCode,int actualSkuQuantity) { PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); row.setActualBinding(true); + row.setActualSkuQuantity(actualSkuQuantity); return row; } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4a6119858..202920809 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,6 +9,13 @@ ## 快速索引 +退货预报、签收、库存、合箱与退货出库: + +- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。 +- 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_*`。 + 人工计划自动生成托盘与资源重试:`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` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java index 1f16c8c93..98054de3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -8,7 +8,11 @@ public final class DropshipRequests { private DropshipRequests() {} public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId, @NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {} - public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {} + public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages, + @NotNull @Positive Integer manifestVersion, + @NotNull @Positive Integer manifestPackageCount, + @NotNull @Positive Integer manifestFragmentCount, + @NotBlank @Size(max = 64) String manifestHash) {} public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, @Positive long productQuantity, @NotEmpty List<@NotBlank String> cartonMarks) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java index 15bc5046a..c91c0aff7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -13,6 +13,10 @@ public class DropshipOrderProfile { private String palletNo; /** Immutable pallet number snapshot supplied by the first-leg handover event. */ private String sourcePalletNo; + private Integer manifestVersion; + private Integer manifestPackageCount; + private Integer manifestFragmentCount; + private String manifestHash; private Long inventoryPositionId; private String status; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index 383c34665..624db797d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select(""" + SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c + JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id + JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id + WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark} + """) + List sourcePalletsInContainer(@Param("containerNo") String containerNo, + @Param("cartonMark") String cartonMark); @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") List lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); @Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index 0d3bf6f05..d881788e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + @Select(""" + SELECT p.* FROM ll_dropship_order_profile p + JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id + WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo} + ORDER BY p.customer_id,p.order_id,p.id + """) + List listContainerPallet(@Param("containerNo") String containerNo, + @Param("sourcePalletNo") String sourcePalletNo); @Select(" """) List selectPendingForOperator(@Param("operatorId") long operatorId, + @Param("warehouseId") long warehouseId, @Param("keyword") String keyword); class PendingTaskRow { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index f3b1a1390..51260d8c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -90,8 +90,8 @@ public class LoadingPickRepository { return tasks.selectLoadingByPlan(loadingPlanId, warehouseId); } - public List pendingTasks(long operatorId, String keyword) { - return tasks.selectPendingForOperator(operatorId, keyword); + public List pendingTasks(long operatorId, long warehouseId, String keyword) { + return tasks.selectPendingForOperator(operatorId, warehouseId, keyword); } public ContainerBookingEntity activeBookingByTask(long taskId) { 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 f8efa803c..c8ca1ce2c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -36,6 +36,7 @@ public class DropshipPalletizationService { return view(task); } if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘"); + if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再生成托盘码"); int expected=mapper.unboundCount(taskId); if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码"); long palletId=IdWorker.getId(); @@ -43,6 +44,10 @@ public class DropshipPalletizationService { mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName); if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试"); + if(mapper.seal(taskId,palletId,1,request.warehouseId(),operation,operatorId,operatorName)!=1) + throw conflict("托盘完成状态已变化,请刷新后重试"); + if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,task.getTenantId(),task.getInboundId(),operatorId)!=1) + throw conflict("一件代发入库完成状态已变化,请刷新后重试"); return view(requireTask(taskId,request.warehouseId())); } @@ -52,7 +57,7 @@ public class DropshipPalletizationService { mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount()); } - public List pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); } + public List pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); } @Transactional public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){ @@ -99,8 +104,8 @@ public class DropshipPalletizationService { if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH"); if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果"); if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明"); - PalletRow pallet=StringUtils.hasText(request.palletNo()) - ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null; + if(StringUtils.hasText(request.palletNo())) throw invalid("逐箱清点阶段不能提前绑定托盘码"); + PalletRow pallet=null; long cartonId=IdWorker.getId(); mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(), label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(), @@ -113,6 +118,7 @@ public class DropshipPalletizationService { @Transactional public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){ TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再绑定托盘码"); String operation=operation(request.operationId()); if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId())); PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName); 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 065ebf1a1..9114087e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -115,7 +115,8 @@ public class InboundCartonLabelPrintService { /** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */ public ResponseEntity generateDropshipPallet(Pallet pallet, String requestId) { - if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { + if (pallet == null || !"SEALED".equals(pallet.status()) + || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印"); } String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) 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 383710d1b..a0a478702 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -233,9 +233,9 @@ public class LoadingPickService { return view(requiredTask(taskId), warehouseId); } - public List pendingTasks(long operatorId, String keyword) { + public List pendingTasks(long operatorId, long warehouseId, String keyword) { String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; - return repository.pendingTasks(operatorId, normalizedKeyword).stream() + return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream() .map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(), row.getCabinetNo(), row.getContainerNo(), row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java new file mode 100644 index 000000000..4dd10242e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** 为国内 PDA 提供启用中的国内仓选择项,主档仍由 Warehouse Service 唯一维护。 */ +@Service +public class PdaWarehouseCatalogService { + @Resource private WarehouseMasterDataClient warehouses; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + public List activeDomesticWarehouses() { + try { + ApiResponse> response = warehouses.domestic(internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw unavailable(); + } + return response.data(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private static BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓目录暂时不可用,请稍后重试"); + } +} diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index bd26d6110..83a769ee2 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -2,12 +2,13 @@ openapi: 3.0.3 info: title: 一件代发国内清点组托 API version: 2.0.0 - description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。 + description: 测量、计费后独立下发。先逐箱确认 SKU 实收数量;全部箱码清点完成后才原子生成、绑定并封存正式 DP 托盘码,随后可打印托盘标签。 paths: /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: summary: 查询待领取或本人已领取任务 x-required-authority: platform:firstleg:pda:view + parameters: [{$ref: '#/components/parameters/WarehouseId'}, {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}] responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: get: @@ -33,8 +34,8 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count: post: - summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘 - description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。 + summary: 逐箱确认 SKU 实收数量 + description: 清点阶段只记录箱级实收并进入 COUNTED_UNBOUND,不得提前生成或绑定托盘码。数量不一致必须 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'}}}} @@ -48,8 +49,8 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind: post: - summary: 系统生成 DP 托盘码并原子绑定全部待绑箱 - description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。 + summary: 完成一件代发入库并生成正式 DP 托盘码 + description: 仅全部箱码已经清点的本人任务可执行;同一事务内生成 DP 托盘码、绑定全部箱、封托并把入仓单推进到待分柜。未完成清点或没有待绑箱时不发码、不建空托。operationId 重放不重复创建;返回已完成 Task。 x-required-authority: platform:firstleg:pda:work parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}} @@ -72,6 +73,17 @@ paths: - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}} responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print: + get: + summary: PDA 打印已封托的一件代发托盘码 + description: 仅当前仓库、当前操作员可访问;未封托托盘拒绝打印。返回 100mm × 100mm PDF。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} + - {$ref: '#/components/parameters/WarehouseId'} + responses: + '200': {description: 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: get: summary: Admin 查询入仓单权威托盘结果 @@ -134,7 +146,6 @@ components: actualSkuQuantity: {type: integer, minimum: 1} discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]} discrepancyRemark: {type: string, maxLength: 500, nullable: true} - palletNo: {$ref: '#/components/schemas/NullablePalletNo'} operationId: {type: string, maxLength: 128} BindPendingRequest: type: object diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 2678da454..be0584e55 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -3,6 +3,17 @@ info: title: Phase 3 Container PDA Picking API version: 1.0.0 paths: + /api/platform/v1/first-leg/pda-loading/warehouses: + get: + summary: List active domestic warehouses available to the PDA operator + responses: + '200': + description: Warehouse master-data options used to scope PDA pending-task lists + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/WarehouseOption'} /api/platform/v1/first-leg/pda-loading/tasks: get: summary: Find the active container picking task in one platform warehouse @@ -18,6 +29,7 @@ paths: get: summary: List active tasks available to the authenticated PDA operator parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} responses: '200': @@ -175,6 +187,13 @@ components: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: + WarehouseOption: + type: object + required: [id, warehouseCode, warehouseName] + properties: + id: {type: integer, format: int64} + warehouseCode: {type: string} + warehouseName: {type: string} Task: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] 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 e247cc0aa..d44557cc2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.repository; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -10,6 +12,17 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class DropshipPalletizationSqlContractTest { + @Test + void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception { + String sql = DropshipPalletizationMapper.class + .getDeclaredMethod("pending", long.class, long.class, String.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("t.warehouse_id=#{warehouseId}") + .contains("t.assignee_id IS NULL OR t.assignee_id=#{operatorId}"); + } + @Test void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception { String inbound = new ClassPathResource("db/phase2-inbound-schema.sql") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index ae16cb39a..20799bb0b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -37,12 +37,13 @@ class LoadingPickPdaTaskSqlContractTest { @Test void pendingTasksAreLimitedToUnassignedOrCurrentOperatorWithoutCustomerInput() throws Exception { String sql = LoadingPickTaskMapper.class - .getDeclaredMethod("selectPendingForOperator", long.class, String.class) + .getDeclaredMethod("selectPendingForOperator", long.class, long.class, String.class) .getAnnotation(Select.class).value()[0]; assertThat(sql) .contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')") .contains("booking.record_status = 'ACTIVE'") + .contains("booking.warehouse_id = #{warehouseId}") .contains("booking.source_type AS sourceType") .contains("booking.record_status AS recordStatus") .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") 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 248544add..8613f5263 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -22,18 +22,27 @@ class DropshipPalletizationServiceTest { DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); } - @Test void generatesAndBindsOnlyWhenPendingCartonsExist(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + @Test void generatesBindsSealsAndCompletesOnlyAfterEveryCartonIsCounted(){ + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001"); when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2); when(mapper.adjustBound(10L,7L,2)).thenReturn(1); + when(mapper.seal(eq(10L),anyLong(),eq(1),eq(100L),eq("create-1"),eq(7L),eq("张三"))).thenReturn(1); + when(mapper.completeTask(10L)).thenReturn(1); when(mapper.completeInbound(10L,1L,30L,7L)).thenReturn(1); service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三"); verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三")); verify(numbers,times(1)).next("DP"); } - @Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){ + @Test void incompleteCountingNeverAllocatesAPalletNumber(){ when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"early"),7L,"张三")) + .hasMessageContaining("完成全部箱码清点"); + verifyNoInteractions(numbers); + } + + @Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){ + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱"); verifyNoInteractions(numbers); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); @@ -48,7 +57,7 @@ class DropshipPalletizationServiceTest { } @Test void rejectsOtherOperatorAndCrossTaskReplay(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"); assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员"); PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other); @@ -59,7 +68,7 @@ class DropshipPalletizationServiceTest { @Test void rejectsUnknownWarehouseAndChangedBindCount(){ var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new"); assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库"); - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002"); assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化"); verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt()); @@ -87,23 +96,16 @@ class DropshipPalletizationServiceTest { verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); } - @Test void countsOneCartonDirectlyIntoScannedPallet(){ - TaskRow before=task(),after=task(); after.setCountedCartons(1); after.setAllocatedCartons(1); - LabelRow label=label(); PalletRow pallet=pallet(); CartonRow carton=carton(); - when(mapper.task(10L,100L)).thenReturn(before,after); - when(mapper.label(10L,"BOX-1")).thenReturn(label); when(mapper.palletByNo("DP-260908-0001")).thenReturn(pallet); - when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton); - when(mapper.pallet(10L,20L,100L)).thenReturn(pallet); when(mapper.cartons(20L)).thenReturn(java.util.List.of(carton)); - when(mapper.metrics(20L)).thenReturn(new DropshipPalletizationMapper.PalletMetrics(java.math.BigDecimal.ONE,java.math.BigDecimal.TEN)); + @Test void countingCannotExposeOrBindAPalletNumberEarly(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1"); - var result=service.countCarton(10L,request,7L,"张三"); - assertThat(result.carton().countStatus()).isEqualTo("BOUND"); - assertThat(result.pallet().status()).isEqualTo("OPEN"); - verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"), - eq("货物"),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")) + .hasMessageContaining("不能提前绑定托盘码"); + verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); } private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; } + private static TaskRow readyToComplete(){ TaskRow t=task();t.setCountedCartons(2);return t; } private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} 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 50df8f106..86c5fff26 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.PrintPdfClient; import com.baiyue.firstleg.dto.InboundCartonLabels; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +37,25 @@ class InboundCartonLabelPrintServiceTest { ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } + @Test + void dropshipPalletLabelIsAvailableOnlyAfterSeal() { + Pallet open = dropshipPallet("OPEN"); + assertThatThrownBy(() -> service.generateDropshipPallet(open, "request-open")) + .isInstanceOf(BizException.class) + .hasMessageContaining("已完成"); + verifyNoInteractions(print); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-sealed"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateDropshipPallet(dropshipPallet("SEALED"), "request-sealed"); + assertThat(response.getBody()).containsExactly(7, 8, 9); + } + @Test void forwardsStableLabelsAndOnlySafePdfHeaders() { when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch()); @@ -159,4 +180,11 @@ class InboundCartonLabelPrintServiceTest { "PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡"); return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label)); } + + private Pallet dropshipPallet(String status) { + SkuItem item = new SkuItem(70001L, "PCHK2608040174", 2L, + "SKU-1", "音箱", 1, 19, List.of("PCHK2608040174#19-1")); + return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0, + BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 2f93adf23..10c5cfe43 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -337,9 +337,9 @@ class LoadingPickServiceTest { row.setAssignmentVersion(0); row.setItemCount(1); row.setPendingItemCount(0); - when(repository.pendingTasks(9L, null)).thenReturn(List.of(row)); + when(repository.pendingTasks(9L, 200001L, null)).thenReturn(List.of(row)); - var result = service.pendingTasks(9L, null).get(0); + var result = service.pendingTasks(9L, 200001L, null).get(0); assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java new file mode 100644 index 000000000..521bb9f12 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PdaWarehouseCatalogServiceTest { + @Test + void returnsDomesticWarehouseMasterDataWithoutHardcodedOptions() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + var option = new WarehouseMasterDataClient.WarehouseOption(200001L, "CN-DONGGUAN-01", "东莞仓"); + when(client.domestic("token")).thenReturn(ApiResponse.success(List.of(option))); + + assertThat(service.activeDomesticWarehouses()).containsExactly(option); + } + + @Test + void failsClosedWhenWarehouseDirectoryIsUnavailable() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + when(client.domestic("token")).thenThrow(new IllegalStateException("down")); + + assertThatThrownBy(service::activeDomesticWarehouses).isInstanceOf(BizException.class); + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ce4dddaca..e8e8b4c46 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,7 +12,7 @@ | 目标 | 入口 | |---|---| -| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | +| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;企业客户编号由 `CustomerCodeRepository` 在创建事务内从 `00001` 起分配固定五位数字,平台技术主体仍为 `PLATFORM`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | | 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` | | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | 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 875d001a9..aca36a421 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -110,7 +110,7 @@ public final class TenantDtos { @Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType, @Schema(description = "Review status / 审核状态", example = "PENDING") String status, @Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName, - @Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode, + @Schema(description = "Customer code / 五位客户编号", example = "00001") String tenantCode, @Schema(description = "Primary contact / 联系人", example = "张三") String contactName, @Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail, diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java new file mode 100644 index 000000000..8cca4e4d9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; + +/** 在客户创建事务内串行分配固定五位客户编号。 */ +@Repository +public class CustomerCodeRepository { + private static final String SEQUENCE_KEY = "CUSTOMER_CODE"; + private static final long MAX_CUSTOMER_CODE = 99_999L; + + @Resource + private JdbcTemplate jdbc; + + @Transactional(propagation = Propagation.MANDATORY) + public String nextCode() { + jdbc.update("INSERT IGNORE INTO iam_number_sequence(sequence_key,next_value) VALUES (?,1)", + SEQUENCE_KEY); + Long value = jdbc.queryForObject( + "SELECT next_value FROM iam_number_sequence WHERE sequence_key=? FOR UPDATE", + Long.class, SEQUENCE_KEY); + if (value == null) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + String code = format(value); + int updated = jdbc.update( + "UPDATE iam_number_sequence SET next_value=next_value+1 WHERE sequence_key=? AND next_value=?", + SEQUENCE_KEY, value); + if (updated != 1) { + throw new BizException(ResultCode.CONFLICT, "客户编号分配冲突,请重试"); + } + return code; + } + + static String format(long value) { + if (value < 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + if (value > MAX_CUSTOMER_CODE) { + throw new BizException(ResultCode.CONFLICT, "五位客户编号已用完"); + } + return String.format(Locale.ROOT, "%05d", value); + } +} 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 a38f0b668..7073f3fd9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -20,6 +20,7 @@ import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; @@ -43,6 +44,7 @@ public class MerchantOnboardingService { @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @Resource private SecurityEventOutboxService securityEvents; + @Resource private CustomerCodeRepository customerCodes; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -66,7 +68,7 @@ public class MerchantOnboardingService { IamTenant tenant = new IamTenant(); tenant.setId(tenantId); - tenant.setTenantCode("TEN" + tenantId); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.companyName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); 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 50221060e..b46d9f380 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -11,6 +11,7 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; @@ -56,6 +57,8 @@ public class TenantService { @Resource private TenantRepository repository; @Resource + private CustomerCodeRepository customerCodes; + @Resource private ProfileResourceRepository profileRepository; @Resource private AuthAccountClient authClient; @@ -165,7 +168,7 @@ public class TenantService { ensureAdministratorPhoneAvailable(contactPhone); IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); - tenant.setTenantCode("TEN" + tenant.getId()); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 4b4dd4f59..1916a76b4 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -7,7 +7,7 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_tenant ( id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', - tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', + tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', @@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; + +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); + CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 9d5da3f64..8abc9a16e 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1113,7 +1113,7 @@ components: reviewType: {type: string, example: REGISTER} status: {type: string, enum: [PENDING]} tenantName: {type: string, description: 客户名称} - tenantCode: {type: string, description: 客户编号} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001', description: 五位客户编号} contactName: {type: string, description: 联系人} contactPhone: {type: string, nullable: true, description: 联系电话} contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱} @@ -1199,7 +1199,7 @@ components: required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} - tenantCode: {type: string} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} @@ -1237,7 +1237,7 @@ components: required: [id, tenantCode, tenantName, status] properties: id: {type: string, pattern: '^\d+$'} - tenantCode: {type: string} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} status: {type: string, enum: [ACTIVE, DISABLED]} contactName: {type: string, nullable: true} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java new file mode 100644 index 000000000..9b7f0ab7d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CustomerCodeRepositoryTest { + @Test + void allocatesFiveDigitCodeFromOne() { + assertEquals("00001", CustomerCodeRepository.format(1)); + assertEquals("99999", CustomerCodeRepository.format(99_999)); + } + + @Test + void rejectsAllocationAfterFiveDigitCapacityIsExhausted() { + BizException failure = assertThrows(BizException.class, + () -> CustomerCodeRepository.format(100_000)); + + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java new file mode 100644 index 000000000..27e88c17d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java @@ -0,0 +1,34 @@ +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 CustomerCodeSchemaContractTest { + @Test + void baselineAndUnifiedSqlInitializeCustomerCodeSequenceAtOne() 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); + + assertSequenceDefinition(baseline); + assertSequenceDefinition(unified); + } + + private static void assertSequenceDefinition(String sql) { + assertThat(sql) + .contains("tenant_code VARCHAR(8) NOT NULL") + .contains("CREATE TABLE IF NOT EXISTS iam_number_sequence") + .contains("PRIMARY KEY (sequence_key)") + .contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java index 92f5639c6..68a289288 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -140,7 +140,7 @@ class CustomerListExportProviderServiceTest { private IamTenant tenant(long id) { IamTenant tenant = new IamTenant(); tenant.setId(id); - tenant.setTenantCode("TEN" + id); + tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantName("柏悦物流" + id); tenant.setLegalName("柏悦物流有限公司"); tenant.setUnifiedSocialCreditCode("91440000123456789X"); 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 2b48686ef..7e4501eb1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -14,7 +14,9 @@ import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -42,7 +44,14 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private CustomerCodeRepository customerCodes; @Resource private MerchantOnboardingService service; + + @BeforeEach + void allocateCustomerCode() { + when(customerCodes.nextCode()).thenReturn("00001"); + } + @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -76,6 +85,7 @@ class MerchantOnboardingServiceTest { assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals("PENDING", result.dimensionInitializationStatus()); assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); + assertEquals("00001", tenant.getValue().getTenantCode()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 6ff844faa..aa1f23fd3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -5,6 +5,7 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -21,6 +22,7 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(TenantService.class) class TenantCustomerExportTest { @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; @MockBean private ProfileResourceRepository profileRepository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authAccountClient; @@ -34,7 +36,7 @@ class TenantCustomerExportTest { void returnsUnmaskedCustomerRowsAndAdvancingCursor() { IamTenant tenant = new IamTenant(); tenant.setId(101L); - tenant.setTenantCode("TEN101"); + tenant.setTenantCode("00101"); tenant.setTenantName("柏悦物流"); tenant.setContactPhone("13800138000"); tenant.setContactEmail("ops@example.com"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index e25b98c87..ce6928eb8 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.mapper.IamRoleMapper; @@ -51,6 +52,7 @@ import com.baiyue.common.core.api.ResultCode; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class TenantServiceTest { @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; @MockBean private ProfileResourceRepository profileRepository; @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @@ -63,6 +65,7 @@ class TenantServiceTest { @BeforeEach void allowUnusedAdministratorPhone() { + when(customerCodes.nextCode()).thenReturn("00001"); when(authClient.availability(eq("internal-token"), any())) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(true)); } @@ -72,7 +75,7 @@ class TenantServiceTest { IamTenant tenant = new IamTenant(); tenant.setId(10001L); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setStatus("ACTIVE"); tenant.setLegalName("Example Merchant Ltd"); @@ -91,7 +94,7 @@ class TenantServiceTest { var result = service.pageMerchants(1, 10, "Example", "ACTIVE"); assertEquals(1, result.total()); - assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("10001", result.records().get(0).tenantCode()); assertEquals("Alice", result.records().get(0).contactName()); assertEquals("Sales Zhang", result.records().get(0).salesOwnerName()); assertEquals(30, result.records().get(0).paymentTermDays()); @@ -108,7 +111,7 @@ class TenantServiceTest { review.setReviewType("REGISTER"); review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27)); IamTenant tenant = merchant(10001L, "PENDING"); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setContactName("Alice"); tenant.setContactPhone("13800138000"); @@ -124,7 +127,7 @@ class TenantServiceTest { assertEquals("REGISTER", result.get(0).reviewType()); assertEquals("PENDING", result.get(0).status()); assertEquals("Example Merchant", result.get(0).tenantName()); - assertEquals("TEN10001", result.get(0).tenantCode()); + assertEquals("10001", result.get(0).tenantCode()); assertEquals("Alice", result.get(0).contactName()); assertEquals("13800138000", result.get(0).contactPhone()); assertEquals("alice@example.com", result.get(0).contactEmail()); @@ -136,7 +139,7 @@ class TenantServiceTest { @Test void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { IamTenant tenant = merchant(10001L, "ACTIVE"); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setContactName("Alice"); tenant.setContactPhone("13800138000"); @@ -145,7 +148,7 @@ class TenantServiceTest { var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); - assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("10001", result.records().get(0).tenantCode()); assertEquals("Alice", result.records().get(0).contactName()); assertEquals("13800138000", result.records().get(0).contactPhone()); verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); @@ -320,6 +323,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("00001", created.tenant().tenantCode()); assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); @@ -515,7 +519,7 @@ class TenantServiceTest { private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); - tenant.setTenantCode("TEN" + id); + tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); tenant.setDimensionInitializationStatus("READY"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java index 2f3b48a3c..048206462 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -22,5 +22,6 @@ public interface DomesticWarehouseDirectoryClient { record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, String warehouseType, String countryCode, String status) { } - record Address(String stateProvince) { } + record Address(String addressType, String countryCode, String stateProvince, String city, + String district, String postalCode, String addressLine1, String addressLine2) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index e3c92791e..537bb0c66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import feign.FeignException; @@ -13,6 +14,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -27,6 +29,7 @@ public class DispatchBolPrintService { @Resource private DispatchService dispatches; @Resource private PrintPdfClient print; + @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public GeneratedPdf generate(String dispatchNo, String requestId) { @@ -52,6 +55,7 @@ public class DispatchBolPrintService { } private List documents(DispatchResponse detail) { + String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); Map> groups = new LinkedHashMap<>(); for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); List documents = new ArrayList<>(); @@ -67,7 +71,7 @@ public class DispatchBolPrintService { List rows = lines.subList(offset, end).stream() .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); - documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), "—", text(detail.carrierName()), + documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); @@ -77,6 +81,41 @@ public class DispatchBolPrintService { return List.copyOf(documents); } + private String warehouseAddress(String warehouseId) { + long id; + try { + id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip()); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓"); + } + try { + var response = warehouses.activeWarehouse(id, internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + List addresses = response.data().addresses(); + if (addresses == null || addresses.isEmpty()) return "—"; + DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull) + .filter(value -> "OPERATING".equals(value.addressType())).findFirst() + .orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null)); + if (address == null) return "—"; + List parts = new ArrayList<>(); + add(parts, address.addressLine1()); + add(parts, address.addressLine2()); + add(parts, address.city()); + add(parts, address.stateProvince()); + add(parts, address.postalCode()); + add(parts, address.countryCode()); + return parts.isEmpty() ? "—" : String.join(", ", parts); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + } + + private void add(List parts, String value) { + if (value != null && !value.isBlank()) parts.add(value.strip()); + } + static int pageCount(int rowCount) { if (rowCount <= FIRST_PAGE_ROWS) return 1; return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java index cf96c781e..f9573422f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -33,6 +33,7 @@ class DomesticWarehouseDirectoryClientContractTest { "warehouseType:String", "countryCode:String", "status:String"); assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) - .containsExactly("stateProvince:String"); + .containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String", + "district:String", "postalCode:String", "addressLine1:String", "addressLine2:String"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index ff6bad8f5..0a297cbc0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -1,10 +1,41 @@ package com.baiyue.lastleg.dispatch.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import com.baiyue.lastleg.client.PrintPdfClient; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +@ExtendWith(MockitoExtension.class) class DispatchBolPrintServiceTest { + @Mock private DispatchService dispatches; + @Mock private PrintPdfClient print; + @Mock private DomesticWarehouseDirectoryClient warehouses; + @InjectMocks private DispatchBolPrintService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); + } + @Test void usesDenseFirstAndContinuationPageCapacities() { assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); @@ -15,4 +46,33 @@ class DispatchBolPrintServiceTest { assertThat(DispatchBolPrintService.pageCount(70)).isEqualTo(3); assertThat(DispatchBolPrintService.pageCount(71)).isEqualTo(4); } + + @Test + void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", + "NYEXX2609110214", null, null, 1))); + var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles", + null, "90001", "Los Angeles warehouse generic address pending maintenance", null); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "1").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-1"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture()); + assertThat(request.getValue().documents()).singleElement().satisfies(document -> + assertThat(document.shipFromAddress()).isEqualTo( + "Los Angeles warehouse generic address pending maintenance, Los Angeles, CA, 90001, US")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java index ed50bf734..1d27139c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java @@ -91,7 +91,8 @@ class FbaWarehouseValidatorTest { return new DomesticWarehouseDirectoryClient.Detail( new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name, type, "US", status), - List.of(new DomesticWarehouseDirectoryClient.Address("CA"))); + List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles", + null, "90001", "123 Logistics Ave", null))); } private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { diff --git a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java index 772fff41f..6067b1a4b 100644 --- a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java @@ -61,7 +61,8 @@ public class DispatchBolRenderer { line(s,u,105,top,105,bottom); line(s,u,left,50,right,50); blackHeader(s,u,f,"SHIP FROM",left,top,105,27); blackHeader(s,u,f,"CARRIER",105,top,right,27); blackHeader(s,u,f,"SHIP TO",left,50,105,56); blackHeader(s,u,f,"REFERENCE",105,50,right,56); - text(s,u,f,d.shipFromName(),9.5f,left+2,33); text(s,u,f,"Address: " + d.shipFromAddress(),7.5f,left+2,40); + text(s,u,f,d.shipFromName(),9.5f,left+2,33); + wrappedText(s,u,f,"Address: " + d.shipFromAddress(),7.2f,left+2,40,91,2,4.5); text(s,u,f,"Carrier Name: " + d.carrierName(),7.5f,107,32); text(s,u,f,"ISA #: " + d.isaNumber(),7.5f,107,36.5); text(s,u,f,"Appointment time: " + d.appointmentTime(),7.5f,107,41); text(s,u,f,"Trailer #: " + d.trailerNumber(),7.5f,107,45.5); text(s,u,f,"Seal #: " + d.sealNumber(),7.5f,107,49); @@ -132,6 +133,21 @@ public class DispatchBolRenderer { 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();} private void signature(PDPageContentStream s,PdfUnits u,PDFont f,double x,double y,String label)throws IOException{line(s,u,x,y,x+52,y);centeredCell(s,u,f,label,6.2f,x,x+52,y+8);} private void centered(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double centerX,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,centerX-w/PdfUnits.mmToPt(1)/2,y);} + private void wrappedText(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x,double y,double width,int maxLines,double lineHeight)throws IOException{ + String remaining=value==null?"—":value.strip(); + for(int lineIndex=0;lineIndex0&&f.getStringWidth(value.substring(0,end))/1000*size>max)end--; + int space=value.lastIndexOf(' ',end);return value.substring(0,space>0?space:end).stripTrailing(); + } private void centeredLines(PDPageContentStream s,PdfUnits u,PDFont f,String[] values,float size,double x1,double x2,double y)throws IOException{for(int i=0;i> domesticWarehouses( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeDomesticWarehouses()); + } + @GetMapping("/shared-overseas") public ApiResponse> sharedOverseasWarehouses( @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java new file mode 100644 index 000000000..88aa7b2dd --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java @@ -0,0 +1,11 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; + +/** 国内 PDA 可选择的启用仓库主档。 */ +public record DomesticWarehouseOptionResponse(Long id, String warehouseCode, String warehouseName) { + public static DomesticWarehouseOptionResponse of(WarehouseEntity warehouse) { + return new DomesticWarehouseOptionResponse( + warehouse.getId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index d6abc3986..0a3dd7d29 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.DomesticWarehouseOptionResponse; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.WarehouseSummaryResponse; @@ -96,6 +97,14 @@ public class WarehouseApplicationService { .toList(); } + public List activeDomesticWarehouses() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_DOMESTIC) + .stream() + .map(DomesticWarehouseOptionResponse::of) + .toList(); + } + public List activeSharedOverseasWarehouses() { return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, WarehouseSaveNormalizer.TYPE_OVERSEAS) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java index f246fcd69..5aa71a2b7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.DomesticWarehouseOptionResponse; import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -85,6 +86,17 @@ class InternalWarehouseControllerTest { verify(service).activeOverseasPartners(); } + @Test + void listsActiveDomesticWarehouseOptionsForInternalCallers() { + var option = new DomesticWarehouseOptionResponse(200001L, "CN-DONGGUAN-01", "东莞仓"); + when(service.activeDomesticWarehouses()).thenReturn(List.of(option)); + + var response = controller.domesticWarehouses(INTERNAL_TOKEN); + + assertThat(response.data()).containsExactly(option); + verify(service).activeDomesticWarehouses(); + } + @Test void exposesSharedOverseasReceivingDirectoryOnlyWithInternalToken() { var warehouse = new SharedOverseasWarehouseResponse(200003L, "US-LAX-01", From 749d403ed40374da47da1128f61783805908e65c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 11:52:41 +0800 Subject: [PATCH 32/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 | 4 ++-- .../dispatch/service/DispatchBolPrintServiceTest.java | 6 +++--- .../src/main/resources/db/warehouse-public-master-data.sql | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ada7ec286..261b98e52 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6828,8 +6828,8 @@ INSERT INTO tmp_wh_fixed_platform_warehouse VALUES (193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'), (193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'), -(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'), -(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance'); +(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','California','Rancho Cucamonga','91730','10767 7th Street'), +(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','New Jersey','Edison','08817','Door 13-14, 75 Carter Dr'); DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; DELIMITER // diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 0a297cbc0..fc6ce4e18 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -56,8 +56,8 @@ class DispatchBolPrintServiceTest { 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", "NYEXX2609110214", null, null, 1))); - var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles", - null, "90001", "Los Angeles warehouse generic address pending maintenance", null); + var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", + null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); @@ -73,6 +73,6 @@ class DispatchBolPrintServiceTest { verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture()); assertThat(request.getValue().documents()).singleElement().satisfies(document -> assertThat(document.shipFromAddress()).isEqualTo( - "Los Angeles warehouse generic address pending maintenance, Los Angeles, CA, 90001, US")); + "10767 7th Street, Rancho Cucamonga, California, 91730, US")); } } diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.sql b/warehouse/src/main/resources/db/warehouse-public-master-data.sql index 7751b86ca..8875427c2 100644 --- a/warehouse/src/main/resources/db/warehouse-public-master-data.sql +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.sql @@ -30,8 +30,8 @@ INSERT INTO tmp_wh_fixed_platform_warehouse VALUES (193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'), (193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'), -(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'), -(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance'); +(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','California','Rancho Cucamonga','91730','10767 7th Street'), +(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','New Jersey','Edison','08817','Door 13-14, 75 Carter Dr'); DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed; DELIMITER // From 69076ea7124f0c41ff7b092b30e307c02fcf1040 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 11:55:46 +0800 Subject: [PATCH 33/63] fix: print overseas warehouse address on BOL --- .../dispatch/service/DispatchBolPrintService.java | 13 ++++++++++--- .../service/DispatchBolPrintServiceTest.java | 2 +- .../warehouse-public-master-data-contract.test.mjs | 14 +++++++++++++- .../db/master-data/platform-fixed-warehouses.csv | 4 ++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index 537bb0c66..4ec0823cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -102,9 +102,12 @@ public class DispatchBolPrintService { List parts = new ArrayList<>(); add(parts, address.addressLine1()); add(parts, address.addressLine2()); - add(parts, address.city()); - add(parts, address.stateProvince()); - add(parts, address.postalCode()); + String city = trimmed(address.city()); + String state = trimmed(address.stateProvince()); + String postalCode = trimmed(address.postalCode()); + String region = city == null ? state : state == null ? city : city + ", " + state; + if (postalCode != null) region = region == null ? postalCode : region + " " + postalCode; + add(parts, region); add(parts, address.countryCode()); return parts.isEmpty() ? "—" : String.join(", ", parts); } catch (FeignException exception) { @@ -116,6 +119,10 @@ public class DispatchBolPrintService { if (value != null && !value.isBlank()) parts.add(value.strip()); } + private String trimmed(String value) { + return value == null || value.isBlank() ? null : value.strip(); + } + static int pageCount(int rowCount) { if (rowCount <= FIRST_PAGE_ROWS) return 1; return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index fc6ce4e18..bd39901b4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -73,6 +73,6 @@ class DispatchBolPrintServiceTest { verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture()); assertThat(request.getValue().documents()).singleElement().satisfies(document -> assertThat(document.shipFromAddress()).isEqualTo( - "10767 7th Street, Rancho Cucamonga, California, 91730, US")); + "10767 7th Street, Rancho Cucamonga, California 91730, US")); } } diff --git a/warehouse/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs index 4693fa5d4..55eeb0d4d 100644 --- a/warehouse/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/scripts/warehouse-public-master-data-contract.test.mjs @@ -95,7 +95,19 @@ assert.deepEqual(fixedWarehouses.map((row) => row.timezone), [ 'UTC-8', 'UTC-5', ]) -assert.ok(fixedWarehouses.every((row) => row.address_line1.includes('待维护') || row.address_line1.includes('pending maintenance'))) +assert.deepEqual( + fixedWarehouses.filter((row) => row.warehouse_type === 'OVERSEAS').map((row) => ({ + warehouse_code: row.warehouse_code, + state_province: row.state_province, + city: row.city, + postal_code: row.postal_code, + address_line1: row.address_line1, + })), + [ + { warehouse_code: 'US-LAX-01', state_province: 'California', city: 'Rancho Cucamonga', postal_code: '91730', address_line1: '10767 7th Street' }, + { warehouse_code: 'US-NYC-01', state_province: 'New Jersey', city: 'Edison', postal_code: '08817', address_line1: 'Door 13-14, 75 Carter Dr' }, + ], +) assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse/) assert.match(sql, /'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC\+8'/) diff --git a/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv b/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv index 82ae74c36..c31a2a01a 100644 --- a/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv +++ b/warehouse/src/main/resources/db/master-data/platform-fixed-warehouses.csv @@ -1,5 +1,5 @@ warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1,note 193000000000200001,193000000000300001,CN-YIWU-01,义乌仓,DOMESTIC,CN,UTC+8,浙江省,义乌市,322000,义乌仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。 193000000000200002,193000000000300002,CN-DONGGUAN-01,东莞仓,DOMESTIC,CN,UTC+8,广东省,东莞市,523000,东莞仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。 -193000000000200003,193000000000300003,US-LAX-01,洛杉矶仓,OVERSEAS,US,UTC-8,CA,Los Angeles,90001,Los Angeles warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。 -193000000000200004,193000000000300004,US-NYC-01,纽约仓,OVERSEAS,US,UTC-5,NY,New York,10001,New York warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。 +193000000000200003,193000000000300003,US-LAX-01,洛杉矶仓,OVERSEAS,US,UTC-8,California,Rancho Cucamonga,91730,10767 7th Street,用户确认的真实海外仓地址。 +193000000000200004,193000000000300004,US-NYC-01,纽约仓,OVERSEAS,US,UTC-5,New Jersey,Edison,08817,"Door 13-14, 75 Carter Dr",用户确认的真实海外仓地址。 From e61050c4908eccd8f778ed6fd87aed58ba0c1cee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 12:12:41 +0800 Subject: [PATCH 34/63] feat: precreate dropship pallet codes after measurement --- ...atformDropshipPalletizationController.java | 16 +-- .../dto/DropshipPalletizationDtos.java | 3 - .../dto/InboundMeasurementRequests.java | 6 + .../dto/InboundMeasurementResponses.java | 2 + .../mapper/DropshipPalletizationMapper.java | 31 ++--- .../service/DropshipPalletizationService.java | 66 ++++------- .../InboundCartonLabelPrintService.java | 11 +- .../service/InboundMeasurementService.java | 20 +++- .../service/MeasurementReviewService.java | 4 +- .../openapi/dropship-palletization-v1.yaml | 27 ++--- .../main/resources/openapi/firstleg-v1.yaml | 3 + .../DropshipPalletizationServiceTest.java | 106 +++++------------- .../InboundCartonLabelPrintServiceTest.java | 11 +- 13 files changed, 108 insertions(+), 198 deletions(-) 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 399f218b6..1ca74b2fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -93,15 +93,6 @@ public class PlatformDropshipPalletizationController { return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username())); } - @PostMapping("/tasks/{taskId}/pallets/create-and-bind") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse createAndBind(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, - @Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username())); - } - @PostMapping("/tasks/{taskId}/cartons/{cartonId}/move") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse moveCarton(@AuthenticationPrincipal AuthenticatedUser user, @@ -130,11 +121,12 @@ public class PlatformDropshipPalletizationController { @RequestParam @Positive long warehouseId, @RequestHeader(value = "X-Request-Id", required = false) String requestId) { FirstlegScopeGuard.platform(user); - Pallet pallet = service.task(taskId, warehouseId, user.userId()).pallets().stream() + Task task = service.task(taskId, warehouseId, user.userId()); + Pallet pallet = task.pallets().stream() .filter(item -> item.palletId() == palletId).findFirst() .orElseThrow(() -> new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在")); - return printService.generateDropshipPallet(pallet, requestId); + return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId); } @GetMapping("/inbound-orders/{inboundId}/pallets") @@ -158,6 +150,6 @@ public class PlatformDropshipPalletizationController { .filter(item -> item.palletId() == palletId).findFirst() .orElseThrow(() -> new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在")); - return printService.generateDropshipPallet(pallet, requestId); + return printService.generateDropshipPallet(pallet, service.inboundCode(accountId, inboundId), requestId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index 78cc53664..75027404b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -15,9 +15,6 @@ public final class DropshipPalletizationDtos { public record ClaimRequest(@Positive long warehouseId) {} - public record CreateAndBindRequest(@Positive long warehouseId, - @NotBlank @Size(max = 128) String operationId) {} - public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } public record CountCartonRequest(@Positive long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index afefd5038..5488d7cdc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -28,8 +28,14 @@ public final class InboundMeasurementRequests { Long measurementId, @NotNull @PositiveOrZero Integer version, @Size(max = 255) String actualGoodsName, + @Positive @Max(1000) Integer expectedPalletCount, @NotEmpty @Size(max = 500) List<@Valid ItemRequest> items ) { + public SaveDraftRequest(Long measurementId, Integer version, String actualGoodsName, + List items) { + this(measurementId, version, actualGoodsName, null, items); + } + @JsonAnySetter public void rejectUnknownField(String fieldName, Object ignored) { throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index c58ec1b5f..deda6cb94 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -19,6 +19,8 @@ public final class InboundMeasurementResponses { String companyName, String channelCode, String channelName, + String fulfillmentType, + Integer expectedPalletCount, String processStatus, String measureReviewStatus, @Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘", 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 200815abf..9abb08f91 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -13,11 +13,6 @@ import java.util.List; /** 一件代发逐箱清点、待绑托与封托事实。 */ @Mapper public interface DropshipPalletizationMapper { - @Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE") - Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId); - - @Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") - PalletRow palletByOpenOperation(@Param("operationId") String operationId); @Insert(""" INSERT IGNORE INTO fl_dropship_pallet_task (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at) @@ -69,14 +64,12 @@ public interface DropshipPalletizationMapper { """) PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId); @Select(""" - SELECT DISTINCT p.* FROM fl_dropship_pallet p - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id - WHERE c.task_id=#{taskId} ORDER BY p.id + SELECT p.* FROM fl_dropship_pallet p + WHERE p.task_id=#{taskId} ORDER BY p.id """) List pallets(@Param("taskId") long taskId); @Select(""" - SELECT DISTINCT p.* FROM fl_dropship_pallet p - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id - WHERE c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId} ORDER BY p.id + SELECT p.* FROM fl_dropship_pallet p + WHERE p.tenant_id=#{tenantId} AND p.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") @@ -139,27 +132,19 @@ public interface DropshipPalletizationMapper { completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion} AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) - AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId}) - AND NOT EXISTS( - SELECT 1 FROM fl_dropship_pallet_task t - WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=t.id) - AND (t.counted_cartons<>t.total_cartons OR t.allocated_cartons<>t.total_cartons - OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton pending WHERE pending.task_id=t.id AND pending.pallet_id IS NULL)) - )""") + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})""") 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(DISTINCT p.id) FROM fl_dropship_pallet p - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id - WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN' + SELECT COUNT(*) FROM fl_dropship_pallet p + WHERE p.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_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); + 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); @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 c8ca1ce2c..c5d59aa5f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -9,7 +9,6 @@ import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*; import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -24,37 +23,16 @@ public class DropshipPalletizationService { @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Transactional - public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){ - if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); - TaskRow task=requireTask(taskId,request.warehouseId()); - if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); - String operation=operation(request.operationId()); - PalletRow replay=mapper.palletByOpenOperation(operation); - if(replay!=null){ - if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId()) - ||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务"); - return view(task); - } - if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘"); - if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再生成托盘码"); - int expected=mapper.unboundCount(taskId); - if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码"); - long palletId=IdWorker.getId(); - String palletNo=documentNumbers.next("DP"); - mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); - int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName); - if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试"); - if(mapper.seal(taskId,palletId,1,request.warehouseId(),operation,operatorId,operatorName)!=1) - throw conflict("托盘完成状态已变化,请刷新后重试"); - if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,task.getTenantId(),task.getInboundId(),operatorId)!=1) - throw conflict("一件代发入库完成状态已变化,请刷新后重试"); - return view(requireTask(taskId,request.warehouseId())); - } - - public void createTask(InboundOrderEntity inbound) { + public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) { if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务"); - mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount()); + if (inbound.getPalletCount()==null || inbound.getPalletCount()<1) throw invalid("一件代发预计托数无效,不能生成托盘码"); + long taskId=IdWorker.getId(); + if(mapper.insertTask(taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount())!=1) return; + for(int sequence=1;sequence<=inbound.getPalletCount();sequence++){ + mapper.insertPallet(IdWorker.getId(),taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(), + documentNumbers.next("DP"),"dropship-precreate-"+taskId+"-"+sequence,operatorId,operatorName); + } } public List pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); } @@ -104,8 +82,7 @@ public class DropshipPalletizationService { if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH"); if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果"); if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明"); - if(StringUtils.hasText(request.palletNo())) throw invalid("逐箱清点阶段不能提前绑定托盘码"); - PalletRow pallet=null; + PalletRow pallet=StringUtils.hasText(request.palletNo())?requireAssignablePallet(task,request.palletNo().trim()):null; long cartonId=IdWorker.getId(); mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(), label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(), @@ -118,10 +95,9 @@ public class DropshipPalletizationService { @Transactional public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){ TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); - if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再绑定托盘码"); String operation=operation(request.operationId()); if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId())); - PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName); + PalletRow pallet=requireAssignablePallet(task,request.palletNo().trim()); int expected=mapper.unboundCount(taskId); if(expected==0) throw conflict("当前没有待绑托箱"); int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName); @@ -136,7 +112,7 @@ public class DropshipPalletizationService { if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在"); if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId()); PalletRow target=StringUtils.hasText(request.targetPalletNo()) - ? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null; + ? requireAssignablePallet(task,request.targetPalletNo().trim()):null; if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task); if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1) throw conflict("清点箱已被其他操作修改,请刷新后重试"); @@ -151,7 +127,6 @@ public class DropshipPalletizationService { PalletRow replay=mapper.palletBySealOperation(operation(request.operationId())); if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } requireOpenPallet(taskId,palletId,request.warehouseId()); - if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托"); if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1) throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){ @@ -170,18 +145,17 @@ public class DropshipPalletizationService { if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在"); return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList(); } + public String inboundCode(long tenantId,long inboundId){ + String code=mapper.inboundCode(tenantId,inboundId); + if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在"); + return code; + } - private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){ + private PalletRow requireAssignablePallet(TaskRow task,String palletNo){ PalletRow existing=mapper.palletByNo(palletNo); - if(existing!=null){ - if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码属于其他仓库"); - if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); - return existing; - } - try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); } - catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); } - existing=mapper.palletByNo(palletNo); - if(existing==null || !Objects.equals(existing.getWarehouseId(),task.getWarehouseId()) || !"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘码已被其他仓库占用或已经封托"); + 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(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); return existing; } 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 9114087e4..58bfd7def 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -114,18 +114,19 @@ public class InboundCartonLabelPrintService { } /** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */ - public ResponseEntity generateDropshipPallet(Pallet pallet, String requestId) { - if (pallet == null || !"SEALED".equals(pallet.status()) - || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { - throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印"); + public ResponseEntity generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) { + if (pallet == null || !StringUtils.hasText(pallet.palletNo())) { + throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); } String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) .filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/")); + if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode; String skuSummary = pallet.items().stream() .map(item -> item.skuCode() + "×" + item.cartonCount()) .collect(java.util.stream.Collectors.joining(" / ")); + if (!StringUtils.hasText(skuSummary)) skuSummary = "待归托"; PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes, - "CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null); + pallet.cartonCount() == 0 ? "CTNS:—" : "CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null); PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest( "INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), pallet.palletNo() + "-托盘唛.pdf", List.of(label)); 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 66a9db379..36200d45d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -131,6 +131,10 @@ public class InboundMeasurementService { throw new BizException(ResultCode.CONFLICT, "测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交"); } + if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType()) + && (request.expectedPalletCount() == null || request.expectedPalletCount() < 1)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发必须录入预计托数"); + } validateInboundDetails(tenantId, inbound, request.items()); request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { @@ -162,6 +166,11 @@ public class InboundMeasurementService { item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), item.chargeRuleId(), item.coefficientMode(), item.remark())).toList()); InboundMeasurementEntity saved = batchService.saveBatch(command); + if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) { + inbound.setPalletCount(request.expectedPalletCount()); + inbound.setUpdatedBy(operatorId); + if (!repository.updateInbound(inbound)) throw concurrentModification(); + } return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus()); } @@ -261,7 +270,13 @@ public class InboundMeasurementService { inbound.setMeasureCompletedAt(LocalDateTime.now()); inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg()); inbound.setActualNetWeightKg(confirmedTotals.netWeightKg()); - inbound.setPalletCount(confirmedTotals.palletCount()); + if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) { + if (inbound.getPalletCount() == null || inbound.getPalletCount() < 1) { + throw new BizException(ResultCode.VALIDATION_FAILED, "请先录入预计托数并保存,再提交测量审核"); + } + } else { + inbound.setPalletCount(confirmedTotals.palletCount()); + } if (measurement.getTotalVolumeCbm() != null) { inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); } @@ -547,7 +562,8 @@ public class InboundMeasurementService { InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot(); return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), - inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), + inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(), + inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), RECOMMENDED_PALLET_TYPE, review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(), review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(), 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 54175a32a..f34ec5bbe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -139,7 +139,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); if (approved && "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) { - dropshipPalletization.createTask(order); + dropshipPalletization.createTask(order, operatorId, operatorName); } String approvedStatus = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()) ? "WAIT_PALLETIZE" : "WAIT_SPLIT"; @@ -179,7 +179,7 @@ public class MeasurementReviewService { throw concurrentConflict(); } if ("ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) { - dropshipPalletization.createTask(order); + dropshipPalletization.createTask(order, operatorId, operatorName); } repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", null, true, request.reason(), true, operatorId, operatorName)); diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 83a769ee2..c5b6880e9 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: 一件代发国内清点组托 API - version: 2.0.0 - description: 测量、计费后独立下发。先逐箱确认 SKU 实收数量;全部箱码清点完成后才原子生成、绑定并封存正式 DP 托盘码,随后可打印托盘标签。 + version: 3.0.0 + 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: 清点阶段只记录箱级实收并进入 COUNTED_UNBOUND,不得提前生成或绑定托盘码。数量不一致必须 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'}}}} @@ -47,14 +47,6 @@ paths: parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}} responses: {'200': {description: OK}} - /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind: - post: - summary: 完成一件代发入库并生成正式 DP 托盘码 - description: 仅全部箱码已经清点的本人任务可执行;同一事务内生成 DP 托盘码、绑定全部箱、封托并把入仓单推进到待分柜。未完成清点或没有待绑箱时不发码、不建空托。operationId 重放不重复创建;返回已完成 Task。 - x-required-authority: platform:firstleg:pda:work - parameters: [{$ref: '#/components/parameters/TaskId'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}} - responses: {'200': {description: Task}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move: post: summary: 换托,targetPalletNo 为空时退回待绑区 @@ -66,7 +58,7 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal: post: - summary: 封托;禁止空托、待绑箱、版本冲突和重复归托 + summary: 封托;禁止空托、版本冲突和重复归托 x-required-authority: platform:firstleg:pda:work parameters: - {$ref: '#/components/parameters/TaskId'} @@ -75,8 +67,8 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print: get: - summary: PDA 打印已封托的一件代发托盘码 - description: 仅当前仓库、当前操作员可访问;未封托托盘拒绝打印。返回 100mm × 100mm PDF。 + summary: PDA 打印预生成的一件代发托盘码 + description: 测量审核通过并生成托盘码后即可打印,空托打印“待归托”。仅当前仓库、当前操作员可访问。返回 100mm × 100mm PDF。 x-required-authority: platform:firstleg:pda:view parameters: - {$ref: '#/components/parameters/TaskId'} @@ -143,6 +135,7 @@ components: properties: warehouseId: {type: integer, format: int64} boxCode: {type: string, maxLength: 80} + palletNo: {$ref: '#/components/schemas/NullablePalletNo'} actualSkuQuantity: {type: integer, minimum: 1} discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]} discrepancyRemark: {type: string, maxLength: 500, nullable: true} @@ -169,10 +162,4 @@ components: expectedVersion: {type: integer, minimum: 1} operationId: {type: string, maxLength: 128} PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'} - CreateAndBindRequest: - type: object - required: [warehouseId, operationId] - properties: - warehouseId: {type: integer, format: int64, minimum: 1} - operationId: {type: string, minLength: 1, maxLength: 128} NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f3389f8c6..a0e04bd50 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1605,6 +1605,8 @@ components: lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} + fulfillmentType: {type: string, nullable: true, enum: [STANDARD, ONE_CLICK_DROPSHIP]} + expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码} taxMode: {type: string, nullable: true} pricingTaxMode: {type: string, nullable: true} containerMode: {type: string, nullable: true} @@ -2000,6 +2002,7 @@ components: measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} version: {type: integer, minimum: 0} actualGoodsName: {type: string, maxLength: 255, nullable: true} + expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发必填;普通入库可不传} items: type: array minItems: 1 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 8613f5263..010bb6496 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -3,9 +3,10 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction; +import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; import org.junit.jupiter.api.Test; @@ -19,94 +20,45 @@ class DropshipPalletizationServiceTest { private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class); private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); private final DropshipPalletizationService service=new DropshipPalletizationService(); - DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); } - @Test void generatesBindsSealsAndCompletesOnlyAfterEveryCartonIsCounted(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); - when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001"); - when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2); - when(mapper.adjustBound(10L,7L,2)).thenReturn(1); - when(mapper.seal(eq(10L),anyLong(),eq(1),eq(100L),eq("create-1"),eq(7L),eq("张三"))).thenReturn(1); - when(mapper.completeTask(10L)).thenReturn(1); when(mapper.completeInbound(10L,1L,30L,7L)).thenReturn(1); - service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三"); - verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三")); - verify(numbers,times(1)).next("DP"); + @Test void reviewCreatesExpectedNumberOfPrintablePalletCodes(){ + InboundOrderEntity inbound=dropshipInbound(); when(mapper.insertTask(anyLong(),eq(1L),eq(30L),eq(100L),eq(8))).thenReturn(1); + when(numbers.next("DP")).thenReturn("DP-260911-0001","DP-260911-0002"); + service.createTask(inbound,7L,"张三"); + verify(mapper,times(2)).insertPallet(anyLong(),anyLong(),eq(1L),eq(30L),eq(100L),startsWith("DP-"),startsWith("dropship-precreate-"),eq(7L),eq("张三")); } - @Test void incompleteCountingNeverAllocatesAPalletNumber(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); - assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"early"),7L,"张三")) - .hasMessageContaining("完成全部箱码清点"); - verifyNoInteractions(numbers); - } - - @Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); - assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱"); - verifyNoInteractions(numbers); - verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); - } - - @Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){ - TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT"); - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed); - when(mapper.palletByOpenOperation("replay")).thenReturn(pallet()); - service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三"); - verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong()); - } - - @Test void rejectsOtherOperatorAndCrossTaskReplay(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); - var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"); - assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员"); - PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other); - assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务"); - verifyNoInteractions(numbers); - } - - @Test void rejectsUnknownWarehouseAndChangedBindCount(){ - var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new"); - assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库"); - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); - when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002"); - assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化"); - verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt()); + @Test void replayedReviewDoesNotGenerateDuplicatePalletCodes(){ + when(mapper.insertTask(anyLong(),anyLong(),anyLong(),anyLong(),anyInt())).thenReturn(0); + service.createTask(dropshipInbound(),7L,"张三"); + verifyNoInteractions(numbers); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); } @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ - TaskRow task=task(); LabelRow label=label(); - when(mapper.task(10L,100L)).thenReturn(task); when(mapper.label(10L,"BOX-1")).thenReturn(label); + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); var result=service.resolveCarton(10L,100L,"BOX-1",7L); - assertThat(result.inboundCode()).isEqualTo("IN-1"); - assertThat(result.skuCode()).isEqualTo("SKU-1"); - assertThat(result.expectedSkuQuantity()).isEqualTo(12); - assertThat(result.countStatus()).isEqualTo("UNCOUNTED"); - assertThat(result.canCount()).isTrue(); + assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.expectedSkuQuantity()).isEqualTo(12); assertThat(result.canCount()).isTrue(); } - @Test void mismatchCannotBeRecordedWithoutAcceptedVarianceAndRemark(){ + @Test void cartonCanBindDirectlyToAPreGeneratedPallet(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet()); + when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton()); + when(mapper.pallet(10L,20L,100L)).thenReturn(pallet()); when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-0001","op-1"),7L,"张三"); + verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"),any(),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + } + + @Test void unknownPalletCodeCannotCreateAnAdHocPallet(){ when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); - var wrongAction=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.MATCH,null,null,"op-1"); - assertThatThrownBy(()->service.countCarton(10L,wrongAction,7L,"张三")) - .isInstanceOf(BizException.class).hasMessageContaining("确认差异处理结果"); - var noRemark=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.ACCEPT_VARIANCE,null,null,"op-2"); - assertThatThrownBy(()->service.countCarton(10L,noRemark,7L,"张三")) - .isInstanceOf(BizException.class).hasMessageContaining("处理说明"); - verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); + var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-9999","op-1"); + assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")).isInstanceOf(BizException.class).hasMessageContaining("测量审核后生成"); + verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); } - @Test void countingCannotExposeOrBindAPalletNumberEarly(){ - when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); - var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1"); - assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")) - .hasMessageContaining("不能提前绑定托盘码"); - verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); - } - - private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; } - private static TaskRow readyToComplete(){ TaskRow t=task();t.setCountedCartons(2);return t; } + private static InboundOrderEntity dropshipInbound(){InboundOrderEntity i=new InboundOrderEntity();i.setId(30L);i.setTenantId(1L);i.setWarehouseId(100L);i.setFulfillmentType("ONE_CLICK_DROPSHIP");i.setTotalPackageCount(8);i.setPalletCount(2);return i;} + private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t;} private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} - private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} - private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} + private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260911-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} + private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setInboundId(30L);c.setInboundDetailId(40L);c.setPalletNo("DP-260911-0001");c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} } 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 86c5fff26..ada636328 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -38,21 +38,16 @@ class InboundCartonLabelPrintServiceTest { } @Test - void dropshipPalletLabelIsAvailableOnlyAfterSeal() { + void dropshipPalletLabelIsAvailableBeforeSeal() { Pallet open = dropshipPallet("OPEN"); - assertThatThrownBy(() -> service.generateDropshipPallet(open, "request-open")) - .isInstanceOf(BizException.class) - .hasMessageContaining("已完成"); - verifyNoInteractions(print); - HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.eq("request-sealed"), org.mockito.ArgumentMatchers.any())) + org.mockito.ArgumentMatchers.eq("request-open"), org.mockito.ArgumentMatchers.any())) .thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers, org.springframework.http.HttpStatus.OK)); - ResponseEntity response = service.generateDropshipPallet(dropshipPallet("SEALED"), "request-sealed"); + ResponseEntity response = service.generateDropshipPallet(open, "PCHK2608040174", "request-open"); assertThat(response.getBody()).containsExactly(7, 8, 9); } From b57ca7def7e0e4902e265ea1c767e2fb26386edf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 12:26:52 +0800 Subject: [PATCH 35/63] fix: align dropship acceptance with precreated pallets --- .../service/DropshipPalletizationService.java | 1 + .../service/InboundMeasurementService.java | 5 + ...oundMeasurementControllerContractTest.java | 2 +- ...tformLoadingPdaControllerContractTest.java | 2 +- .../inbound-measurement.mjs | 107 ++++++++---------- 5 files changed, 57 insertions(+), 60 deletions(-) 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 c5d59aa5f..924cbc16c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -27,6 +27,7 @@ public class DropshipPalletizationService { if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务"); if (inbound.getPalletCount()==null || inbound.getPalletCount()<1) throw invalid("一件代发预计托数无效,不能生成托盘码"); + if (inbound.getPalletCount()>inbound.getTotalPackageCount()) throw invalid("一件代发预计托数不能超过总箱数"); long taskId=IdWorker.getId(); if(mapper.insertTask(taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount())!=1) return; for(int sequence=1;sequence<=inbound.getPalletCount();sequence++){ 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 36200d45d..b8c26a5e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -135,6 +135,11 @@ public class InboundMeasurementService { && (request.expectedPalletCount() == null || request.expectedPalletCount() < 1)) { throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发必须录入预计托数"); } + if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType()) + && inbound.getTotalPackageCount() != null + && request.expectedPalletCount() > inbound.getTotalPackageCount()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发预计托数不能超过总箱数"); + } validateInboundDetails(tenantId, inbound, request.items()); request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index b70b2dd77..fae7ddf8a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -37,7 +37,7 @@ class PlatformInboundMeasurementControllerContractTest { void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() { assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents()) .map(RecordComponent::getName)).containsExactly( - "measurementId", "version", "actualGoodsName", "items"); + "measurementId", "version", "actualGoodsName", "expectedPalletCount", "items"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index c544acbb8..2c58647f8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -13,7 +13,7 @@ class PlatformLoadingPdaControllerContractTest { @Test void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, Long.class, long.class); - authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class); + authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, String.class); authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); authority("assign", "platform:firstleg:pda:assign", AuthenticatedUser.class, long.class, AssignRequest.class); diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 7ccb05bc8..d088c1f97 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -90,7 +90,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) { const pendingResponse = await context.request('GET', - `/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`) + `/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`) const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse, `inboundId=${inboundId}`, OPTIONS) const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId @@ -110,31 +110,17 @@ export async function completeDropshipPalletization(context, { inboundId, inboun `taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS) const totalCartons = Number(measurement?.actualPackageCount) - const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind` + const expectedPalletCount = Number(measurement?.expectedPalletCount) const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}` - const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) } - let palletNo = '' + const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : [] + context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse, + generatedPallets.length === expectedPalletCount && generatedPallets.every((item) => + item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')), + `expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS) + if (!generatedPallets.length) return null + let countedCartons = 0 const sealedCartonMarks = [] - let pallet = null - - const palletSnapshot = (value) => JSON.stringify({ - counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons, - pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo, - count: item.cartonCount, status: item.status, version: item.version })), - }) - async function rejectEmptyCreation(before, suffix) { - const response = await context.request('POST', createPath, - { ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) }) - context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response, - response.http === 200 && String(response.body?.code) === 'A0409', - `HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS) - const afterResponse = await context.request('GET', taskPath) - const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS) - context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse, - palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS) - } - await rejectEmptyCreation(claimed, 'before-count') for (let index = 0; index < totalCartons; index += 1) { const boxCode = `${inboundCode}#${totalCartons}-${index + 1}` @@ -153,7 +139,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun actualSkuQuantity: Number(resolved.expectedSkuQuantity), discrepancyAction: 'MATCH', discrepancyRemark: null, - palletNo: index === 0 ? null : palletNo, + palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo, operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128), } const countResponse = await context.request('POST', @@ -184,55 +170,57 @@ export async function completeDropshipPalletization(context, { inboundId, inboun && Number(replayed?.unboundCartons) === Number(counted?.unboundCartons), `counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS) - const bindResponse = await context.request('POST', createPath, createPayload) - const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse, + const firstPallet = generatedPallets[0] + const bindPayload = { warehouseId, palletNo: firstPallet.palletNo, + operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) } + const bindResponse = await context.request('POST', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload) + const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse, `taskId=${taskId}`, OPTIONS) - pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null - palletNo = pallet?.palletNo || '' + const pallet = bound?.pallets?.find((item) => asId(item.palletId) === asId(firstPallet.palletId)) || null context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse, Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1 - && pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo) - && bound?.pallets?.length === 1, + && pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo + && bound?.pallets?.length === expectedPalletCount, `unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS) - const createReplayResponse = await context.request('POST', createPath, createPayload) - const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS) - context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse, - palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS) - await rejectEmptyCreation(bound, 'after-bind') } else { - pallet = counted?.pallet || pallet + const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)] context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse, - counted?.pallet?.palletNo === palletNo && counted?.pallet?.status === 'OPEN', + counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN', `palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS) } } - context.assert(MODULE, '一件代发工作托盘覆盖测量冻结的全部箱数', null, - totalCartons > 0 && countedCartons === totalCartons && Number(pallet?.cartonCount) === totalCartons, - `counted=${countedCartons}, palletCartons=${pallet?.cartonCount ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS) + const countedTaskResponse = await context.request('GET', taskPath) + let sealed = context.apiSuccess(MODULE, '清点完成后回查预生成托盘分配', countedTaskResponse, '', OPTIONS) + context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse, + totalCartons > 0 && countedCartons === totalCartons + && Number(sealed?.boundCartons) === totalCartons + && sealed?.pallets?.length === expectedPalletCount + && sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0), + `counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS) - const palletId = asId(pallet?.palletId) - if (!palletId) return null - - const sealResponse = await context.request('POST', - `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${palletId}/seal`, { - warehouseId, expectedVersion: Number(pallet?.version), - operationId: `${context.marker}-dropship-seal`.slice(0, 128), - }) - const sealed = context.apiSuccess(MODULE, '封托并完成一件代发合托任务', sealResponse, - `taskId=${taskId}, palletId=${palletId}`, OPTIONS) - context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', sealResponse, + let lastSealResponse = countedTaskResponse + for (let index = 0; index < (sealed?.pallets || []).length; index += 1) { + const pallet = sealed.pallets.find((item) => item.status === 'OPEN') + if (!pallet) break + const sealResponse = await context.request('POST', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${asId(pallet.palletId)}/seal`, { + warehouseId, expectedVersion: Number(pallet.version), + operationId: `${context.marker}-dropship-seal-${index + 1}`.slice(0, 128), + }) + lastSealResponse = sealResponse + sealed = context.apiSuccess(MODULE, `封托第${index + 1}个预生成托盘`, sealResponse, + `taskId=${taskId}, palletNo=${pallet.palletNo}`, OPTIONS) + } + context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', lastSealResponse, sealed?.taskStatus === 'COMPLETED' && Number(sealed?.countedCartons) === Number(sealed?.totalCartons) && Number(sealed?.boundCartons) === Number(sealed?.totalCartons) && Number(sealed?.unboundCartons) === 0 && sealed?.pallets?.every((item) => item?.status === 'SEALED'), `taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS) - const sealedReplayResponse = await context.request('POST', createPath, createPayload) - const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS) - context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse, - sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed), - `taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS) - return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [palletNo] } + return { ...sealed, sealedCartonMarks, + sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) } } async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) { @@ -846,9 +834,13 @@ export async function run(context) { ? 'ADD' : measuredItem.coefficientMode ?? null, remark: context.marker, }] + const palletCount = Math.max(1, Math.min(originalPackageCount, + Number(context.context?.waitSplitVariant?.palletCount || 1))) const draftPayload = { measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, + expectedPalletCount: context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP' + ? palletCount : null, items: draftItems, } const draftResponse = await context.request('PUT', @@ -864,7 +856,6 @@ export async function run(context) { return } - const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1)) const warehouseResourcesReady = await provisionWarehouseResources(context, { inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount, }) From 1f20516d23b5fc7f1f4beb91e1731cccbcfea073 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 12:27:41 +0800 Subject: [PATCH 36/63] docs: update dropship pallet acceptance flow --- scripts/admin-test-env-modules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 836860fe0..bce5f7498 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` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。 +一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。 该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。 From 137f8173ab42283df261e083e42928ce0bb37625 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 12:36:12 +0800 Subject: [PATCH 37/63] fix: keep full-chain acceptance aligned with current APIs --- scripts/admin-test-env-modules/phase3-pda-convergence.mjs | 2 +- scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 2c42f2d0d..63970322b 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -53,7 +53,7 @@ export async function run(context) { `${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`) const taskDetail = context.apiSuccess(MODULE, '按稳定 taskId 读取 PDA 任务详情', taskDetailResponse, '', OPTIONS) const pendingResponse = await request(context, 'GET', - `/api/platform/v1/first-leg/pda-loading/tasks/pending?keyword=${encodeURIComponent(containerNo)}`) + `/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}`) const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS) const pendingTask = Array.isArray(pendingTasks) ? pendingTasks.find((item) => id(item?.taskId) === taskId) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index aa26e51a1..b6268b4af 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -93,8 +93,10 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) { async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture - if (!fixture?.username || !fixture?.password) return null - const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') + const username = fixture?.username || context.options?.merchantUsername + const password = fixture?.password || context.options?.merchantPassword + if (!username || !password) return null + const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const data = success(response) ? response.body?.data : null context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, success(response) && nonBlank(data?.overseasAccessToken), From 3b97eedc3d979e9ada251e3019304b0c6f29487d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 13:54:06 +0800 Subject: [PATCH 38/63] fix: block dropship work until pallets are ready --- firstleg/AGENTS.md | 2 +- .../firstleg/dto/DropshipPalletizationDtos.java | 4 +++- .../mapper/DropshipPalletizationMapper.java | 6 ++++-- .../service/DropshipPalletizationService.java | 15 +++++++++++++-- .../openapi/dropship-palletization-v1.yaml | 16 ++++++++++++++++ .../DropshipPalletizationServiceTest.java | 16 ++++++++++++---- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index e3a0f9bc4..d40a3a231 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -20,7 +20,7 @@ | Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | -| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | +| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成可打印 `DP` 托盘码并下发独立任务,不创建国内库存;PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index 75027404b..6d204acfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -46,7 +46,9 @@ public final class DropshipPalletizationDtos { public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, String customerName, Long warehouseId, String warehouseName, Integer totalCartons, Integer countedCartons, Integer boundCartons, - Integer unboundCartons, Integer discrepancyCartons, String taskStatus, + Integer unboundCartons, Integer discrepancyCartons, + Integer expectedPalletCount, Integer generatedPalletCount, + Boolean palletSetupReady, String palletSetupMessage, String taskStatus, Long assigneeId, String assigneeName, Integer version, List pallets, List unbound) {} 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 9abb08f91..affe179ed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -33,7 +33,8 @@ public interface DropshipPalletizationMapper { @Param("keyword") String keyword); @Select(""" - SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type + SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type, + o.pallet_count expected_pallet_count FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""") TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId); @@ -56,6 +57,7 @@ public interface DropshipPalletizationMapper { @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 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(""" SELECT p.* FROM fl_dropship_pallet p @@ -197,7 +199,7 @@ public interface DropshipPalletizationMapper { ORDER BY c.inbound_detail_id """) List sealedPalletLines(@Param("palletId") long palletId); - @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; } + @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; } @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; } @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } 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 924cbc16c..5d3c63759 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -40,6 +40,7 @@ public class DropshipPalletizationService { @Transactional public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){ + requirePalletSetup(requireTask(taskId,warehouseId)); if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化"); return task(taskId,warehouseId,operatorId); } @@ -60,6 +61,7 @@ public class DropshipPalletizationService { if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务"; else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员"; else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态"; + else if(!palletSetupReady(task)) block=palletSetupMessage(task); return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(), label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null, block==null,block); @@ -167,9 +169,14 @@ 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(); + 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(), - row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(), + expected,generated,palletSetupReady,palletSetupReady?null:palletSetupMessage(expected,generated), + row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),pallets, mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList()); } @@ -193,7 +200,11 @@ public class DropshipPalletizationService { } private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; } private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); } - private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; } + private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); requirePalletSetup(task); return task; } + private void requirePalletSetup(TaskRow task){ if(!palletSetupReady(task)) throw conflict(palletSetupMessage(task)); } + private boolean palletSetupReady(TaskRow task){ return task.getExpectedPalletCount()!=null && task.getExpectedPalletCount()>0 && mapper.palletCount(task.getId())==task.getExpectedPalletCount(); } + private String palletSetupMessage(TaskRow task){ int expected=task.getExpectedPalletCount()==null?0:task.getExpectedPalletCount(); return palletSetupMessage(expected,mapper.palletCount(task.getId())); } + private static String palletSetupMessage(int expected,int generated){ return "预计 "+expected+" 托,当前仅生成 "+generated+" 个托盘码,请联系后台处理后刷新"; } private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; } private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; } private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;} diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index c5b6880e9..c94c5d668 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -91,6 +91,22 @@ components: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}} WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}} schemas: + Task: + type: object + description: 一件代发清点组托任务;palletSetupReady=false 时禁止领取、解析箱码和提交清点。 + required: [taskId, totalCartons, countedCartons, boundCartons, unboundCartons, expectedPalletCount, generatedPalletCount, palletSetupReady, taskStatus, pallets] + properties: + taskId: {type: integer, format: int64} + totalCartons: {type: integer, minimum: 1} + countedCartons: {type: integer, minimum: 0} + boundCartons: {type: integer, minimum: 0} + unboundCartons: {type: integer, minimum: 0} + expectedPalletCount: {type: integer, minimum: 1} + generatedPalletCount: {type: integer, minimum: 0} + palletSetupReady: {type: boolean} + palletSetupMessage: {type: string, nullable: true} + taskStatus: {type: string, enum: [PENDING, CLAIMED, COMPLETED]} + pallets: {type: array, items: {$ref: '#/components/schemas/Pallet'}} Carton: type: object properties: 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 010bb6496..bd2c3ff8e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -36,13 +36,13 @@ class DropshipPalletizationServiceTest { } @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ - when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); var result=service.resolveCarton(10L,100L,"BOX-1",7L); assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.expectedSkuQuantity()).isEqualTo(12); assertThat(result.canCount()).isTrue(); } @Test void cartonCanBindDirectlyToAPreGeneratedPallet(){ - when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet()); + 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(pallet()); when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton()); when(mapper.pallet(10L,20L,100L)).thenReturn(pallet()); when(mapper.cartons(20L)).thenReturn(java.util.List.of()); service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-0001","op-1"),7L,"张三"); @@ -50,14 +50,22 @@ class DropshipPalletizationServiceTest { } @Test void unknownPalletCodeCannotCreateAnAdHocPallet(){ - when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-9999","op-1"); assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")).isInstanceOf(BizException.class).hasMessageContaining("测量审核后生成"); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); } private static InboundOrderEntity dropshipInbound(){InboundOrderEntity i=new InboundOrderEntity();i.setId(30L);i.setTenantId(1L);i.setWarehouseId(100L);i.setFulfillmentType("ONE_CLICK_DROPSHIP");i.setTotalPackageCount(8);i.setPalletCount(2);return i;} - private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t;} + @Test void incompletePreGeneratedPalletsBlockCounting(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(0); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var result=service.resolveCarton(10L,100L,"BOX-1",7L); + assertThat(result.canCount()).isFalse(); assertThat(result.blockReason()).contains("预计 2 托").contains("当前仅生成 0 个托盘码"); + assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,null,"op-1"),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("预计 2 托"); + } + + private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setExpectedPalletCount(2);t.setVersion(1);return t;} private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260911-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setInboundId(30L);c.setInboundDetailId(40L);c.setPalletNo("DP-260911-0001");c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} From 183b242d453ed1e2450293d63b5b636f2aa8e976 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 15:01:17 +0800 Subject: [PATCH 39/63] test: cover PDA return receipt lifecycle --- .../service/ReturnReceiptServiceTest.java | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java 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 new file mode 100644 index 000000000..37bbda188 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; +import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt; +import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptBatch; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReturnReceiptServiceTest { + @Mock ReturnOrderMapper orders; + @Mock ReturnOrderItemMapper orderItems; + @Mock ReturnReceiptMapper receipts; + @Mock ReturnReceiptItemMapper receiptItems; + @Mock ReturnReceiptBatchMapper batches; + @Mock ReturnInventoryService inventory; + @Mock LastlegOrderNoGenerator numbers; + @Mock LastlegOrderMapper lastlegOrders; + + ReturnReceiptService service; + PdaWarehouseIdentity identity; + + @BeforeEach + void setUp() { + service = new ReturnReceiptService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderItems", orderItems); + ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "receiptItems", receiptItems); + ReflectionTestUtils.setField(service, "batches", batches); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "numbers", numbers); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); + identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L); + } + + @Test + void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3); + ReturnOrderItem remaining = item(11L, "SKU-A", 5, 2); + ReturnOrderItem completed = item(12L, "SKU-B", 3, 3); + when(orders.findActiveNo("10", "RT-1")).thenReturn(order); + when(orderItems.listOrder(1L)).thenReturn(List.of(remaining, completed)); + + var result = service.start(identity, new PdaReturnReceiptRequests.Start(" RT-1 ")); + + assertEquals("RT-1", result.returnNo()); + assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, result.status()); + assertEquals(List.of("SKU-A"), result.expectedItems().stream().map(item -> item.skuCode()).toList()); + assertEquals(2, result.expectedItems().get(0).receivedQuantity()); + } + + @Test + void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1); + ReturnOrderItem item = item(11L, "SKU-A", 5, 1); + when(orderItems.activeSkuCandidates("10", "SKU-A")).thenReturn(List.of(item)); + when(orders.selectBatchIds(any())).thenReturn(List.of(order)); + + var result = service.skuCandidates(identity, new PdaReturnReceiptRequests.SkuLookup(" SKU-A ")); + + assertEquals("SKU-A", result.skuCode()); + assertEquals(1, result.candidates().size()); + assertEquals("RT-1", result.candidates().get(0).returnNo()); + assertEquals(1, result.candidates().get(0).skuReceivedQuantity()); + verify(orderItems).activeSkuCandidates("10", "SKU-A"); + } + + @Test + void partialReceiptCreatesOneCartonAndBillingBatchAndKeepsOrderOpen() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1); + ReturnOrderItem first = item(11L, "SKU-A", 3, 1); + ReturnOrderItem second = item(12L, "SKU-B", 2, 0); + stubNewReceipt(order, List.of(first, second), "RC-1"); + + var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", + List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))); + + assertFalse(result.fullyMatched()); + assertEquals(ReturnReceiptService.PARTIALLY_RECEIVED, result.orderStatus()); + assertEquals(2, result.receivedQuantity()); + assertEquals(2, first.getReceivedQuantity()); + 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)); + } + + @Test + void finalReceiptCompletesTheOrderAndIdempotentReplayDoesNotWriteTwice() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1); + ReturnOrderItem line = item(11L, "SKU-A", 2, 1); + stubNewReceipt(order, List.of(line), "RC-AUTO"); + var request = confirm("SKU", null, null, "key-final", + List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))); + + var first = service.confirm(identity, request); + ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); + verify(receipts).insert(savedReceipt.capture()); + when(receipts.findKey("operator-1", "key-final")).thenReturn(savedReceipt.getValue()); + when(orders.selectById(1L)).thenReturn(order); + var replay = service.confirm(identity, request); + + assertTrue(first.fullyMatched()); + assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus()); + assertEquals(first.receiptNo(), replay.receiptNo()); + assertEquals("RC-AUTO", replay.cartonMark()); + verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any()); + verify(batches, times(1)).insert(any(ReturnReceiptBatch.class)); + verify(receipts, times(1)).insert(any(ReturnReceipt.class)); + } + + @Test + void rejectsOverReceiptBeforeCreatingReceiptInventoryOrBatch() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1); + ReturnOrderItem line = item(11L, "SKU-A", 2, 1); + when(receipts.findKey("operator-1", "key-over")).thenReturn(null); + when(orders.lockNo("RT-1")).thenReturn(order); + when(orderItems.lockOrder(1L)).thenReturn(List.of(line)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, + confirm("RETURN_NO", null, null, "key-over", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2))))); + + assertEquals(409, error.getStatusCode().value()); + verify(receipts, never()).insert(any(ReturnReceipt.class)); + verifyNoInteractions(inventory, batches); + } + + @Test + void rejectsAnotherWarehousesOrderBeforeLockingSkuOrWritingInventory() { + ReturnOrder order = order("RT-1", "11", ReturnReceiptService.PENDING_RECEIPT, 2, 0); + when(receipts.findKey("operator-1", "key-cross")).thenReturn(null); + when(orders.lockNo("RT-1")).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, + confirm("RETURN_NO", null, null, "key-cross", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))))); + + assertEquals(404, error.getStatusCode().value()); + verify(orderItems, never()).lockOrder(any()); + verifyNoInteractions(inventory, batches); + } + + @Test + void rejectsReuseOfAnIdempotencyKeyForDifferentReceiptContent() { + ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 2, 0); + ReturnOrderItem line = item(11L, "SKU-A", 2, 0); + stubNewReceipt(order, List.of(line), "RC-1"); + var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", + List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))); + service.confirm(identity, original); + ArgumentCaptor savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class); + verify(receipts).insert(savedReceipt.capture()); + when(receipts.findKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue()); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity, + confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1", + List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))))); + + assertEquals(409, error.getStatusCode().value()); + verify(inventory, times(1)).createReceiptCarton(any(), any(), anyString(), any()); + verify(batches, times(1)).insert(any(ReturnReceiptBatch.class)); + } + + private void stubNewReceipt(ReturnOrder order, List items, String cartonMark) { + when(receipts.findKey(eq("operator-1"), anyString())).thenReturn(null); + when(orders.lockNo("RT-1")).thenReturn(order); + when(orderItems.lockOrder(1L)).thenReturn(items); + when(orderItems.listOrder(1L)).thenReturn(items); + when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1"); + when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1"); + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setCartonMark(cartonMark); + when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton); + } + + private static ReturnOrder order(String returnNo, String warehouseId, String status, long expected, long received) { + ReturnOrder order = new ReturnOrder(); + order.setId(1L); + order.setCustomerId("customer-1"); + order.setReturnNo(returnNo); + order.setCustomerReferenceNo("REF-1"); + order.setOverseasWarehouseId(warehouseId); + order.setStatus(status); + order.setExpectedQuantity(expected); + order.setReceivedQuantity(received); + return order; + } + + private static ReturnOrderItem item(long id, String sku, long expected, long received) { + ReturnOrderItem item = new ReturnOrderItem(); + item.setId(id); + item.setReturnOrderId(1L); + item.setCustomerId("customer-1"); + item.setSkuCode(sku); + item.setProductNameZh("商品 " + sku); + item.setExpectedQuantity(expected); + item.setReceivedQuantity(received); + return item; + } + + private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark, + String key, List items) { + return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key); + } +} From 0db11517dba9ff4a911b8ae8621d1fd4276ee7cc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 15:10:01 +0800 Subject: [PATCH 40/63] fix: persist return forecast tail references --- deploy/sql/头程数据库统一增量-MySQL8.sql | 15 ++++++++++++ .../service/ReturnReceiptService.java | 4 +++- .../src/main/resources/db/lastleg.sql | 4 +++- .../service/ReturnReceiptServiceTest.java | 24 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 261b98e52..459e78656 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -19356,3 +19356,18 @@ 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 $$ +CREATE PROCEDURE baiyue_lastleg.converge_return_order_tail_reference() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_id') THEN + ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT '尾程订单ID' AFTER customer_reference_no; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN + ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号' AFTER lastleg_order_id; + END IF; +END$$ +DELIMITER ; +CALL baiyue_lastleg.converge_return_order_tail_reference(); +DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index a44bf3f5f..06454179c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -106,12 +106,14 @@ public class ReturnReceiptService { tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId())); tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL"); - tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(returnOrder.getId())); + tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo()); + tail.setSourceOrderId(String.valueOf(returnOrder.getId())); tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED"); tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo()); tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now); + tail.setSourcePayloadHash(returnOrder.getRequestHash()); lastlegOrders.insert(tail); returnOrder.setLastlegOrderId(tail.getId()); returnOrder.setLastlegOrderNo(tail.getOrderNo()); returnOrder.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED"); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2dfacc2d6..21ec376d0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2360,10 +2360,12 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( -- 客户退货预报、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 '最后更新时间', + 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 '客户参考号', lastleg_order_id BIGINT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) 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='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''尾程订单ID'' AFTER customer_reference_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_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT ''尾程订单号'' AFTER lastleg_order_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_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; 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 37bbda188..f4478ba23 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.returnreceipt.service; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests; import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; @@ -25,6 +26,7 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -85,6 +87,28 @@ class ReturnReceiptServiceTest { assertEquals(2, result.expectedItems().get(0).receivedQuantity()); } + @Test + void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() { + AtomicReference saved = new AtomicReference<>(); + when(orders.findKey("customer-1", "create-key")).thenReturn(null); + when(numbers.next(eq("RETURN"), eq("10"), anyLong())).thenReturn("RT-1"); + when(numbers.next(eq("RETURN_RELABEL"), eq("10"), anyLong())).thenReturn("RETU-1"); + when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); + when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get()); + when(orderItems.listOrder(anyLong())).thenReturn(List.of()); + + var result = service.create("customer-1", "merchant-1", new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Create( + "10", "测试仓", "0000000000", "REF-1", false, "测试", "create-key", + List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Item("SKU-A", "商品A", 2)))); + + ArgumentCaptor tail = ArgumentCaptor.forClass(LastlegOrder.class); + verify(lastlegOrders).insert(tail.capture()); + assertEquals("RT-1", tail.getValue().getSourceInboundId()); + assertEquals("RETURN_ORDER:RT-1", tail.getValue().getSourceEventId()); + assertEquals(saved.get().getRequestHash(), tail.getValue().getSourcePayloadHash()); + assertEquals("RETU-1", result.order().lastlegOrderNo()); + } + @Test void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1); From cc7c2f09548baa1caf2f564fe45ddd87670c240f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 15:38:19 +0800 Subject: [PATCH 41/63] feat: support mixed-SKU dropship cartons --- deploy/sql/头程数据库统一增量-MySQL8.sql | 35 ++++ firstleg/AGENTS.md | 6 +- .../firstleg/client/OrderCargoClient.java | 6 +- .../firstleg/client/PrintPdfClient.java | 16 ++ .../dto/DropshipPalletizationDtos.java | 21 ++- .../mapper/DropshipPalletizationMapper.java | 40 ++++- .../mapper/InboundCartonLabelSkuMapper.java | 48 +++++ .../service/DropshipPalletizationService.java | 69 +++++--- .../InboundCartonLabelPrintService.java | 29 +-- .../service/InboundCartonLabelService.java | 59 ++++++- .../db/phase2-inbound-carton-label.sql | 16 ++ .../resources/db/phase2-inbound-schema.sql | 33 ++++ .../openapi/dropship-palletization-v1.yaml | 44 +++-- .../DropshipPalletizationServiceTest.java | 17 +- .../InboundCartonLabelPrintServiceTest.java | 20 ++- .../InboundCartonLabelServiceTest.java | 29 +++ .../InternalOrderCargoController.java | 14 +- .../com/baiyue/order/dto/OrderRequests.java | 23 ++- .../baiyue/order/entity/OrderCargoEntity.java | 2 +- .../MerchantInboundOrderListService.java | 8 +- .../service/OrderApplicationService.java | 99 +++++++++-- .../service/OrderCargoPackageCounts.java | 24 +++ .../service/OrderInboundOutboxPublisher.java | 9 +- .../PrintableInboundSnapshotService.java | 5 +- .../service/UnifiedOrderQueryService.java | 12 +- order/src/main/resources/db/order.sql | 1 + .../resources/db/phase1-order-extension.sql | 1 + .../src/main/resources/openapi/order-v1.yaml | 9 +- ...MerchantBatchDraftOpenApiContractTest.java | 2 + .../OrderApplicationServiceStateTest.java | 79 ++++++++- .../service/OrderCargoPackageCountsTest.java | 29 +++ print/AGENTS.md | 10 +- print/README.md | 3 +- .../controller/InternalPdfController.java | 16 ++ .../print/dto/DropshipPalletA4PdfDtos.java | 23 +++ .../renderer/DropshipPalletA4Renderer.java | 167 ++++++++++++++++++ .../print/renderer/PdfBarcodeRenderer.java | 36 ++++ .../service/DropshipPalletA4PdfService.java | 100 +++++++++++ .../resources/openapi/print-internal-v1.yaml | 63 +++++++ .../controller/InternalPdfControllerTest.java | 53 ++++++ .../renderer/PdfBarcodeRendererTest.java | 22 +++ ...pshipPalletA4PdfServiceGenerationTest.java | 93 ++++++++++ 42 files changed, 1263 insertions(+), 128 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundCartonLabelSkuMapper.java create mode 100644 order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java create mode 100644 print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java create mode 100644 print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java create mode 100644 print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java create mode 100644 print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java create mode 100644 print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java create mode 100644 print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 459e78656..bb8add3c1 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1793,6 +1793,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 'Cargo Amazon Shipment ID'"); CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'"); CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'"); +CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'"); CALL phase1_add_column('ord_order_cargo', 'wooden_product', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检'"); UPDATE ord_order_cargo cargo @@ -2906,6 +2907,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; +CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku ( + id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', + expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量', + line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id), + KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code), + CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) +) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照'; + -- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。 DROP PROCEDURE IF EXISTS unified_carton_label_add_column; @@ -18242,6 +18260,23 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) ) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; +CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku ( + id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID', + carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', + expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量', + actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量', + discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE', + discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明', + line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序', + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id), + KEY idx_dropship_carton_sku_code (sku_code,carton_id), + CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id) +) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实'; + -- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。 DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index d40a3a231..32956daa5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -19,8 +19,8 @@ | 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | | Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | -| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | -| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成可打印 `DP` 托盘码并下发独立任务,不创建国内库存;PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | +| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | @@ -65,7 +65,7 @@ - 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 -- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 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/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ee11c2d60..45387e73e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -65,18 +65,18 @@ public interface OrderCargoClient { record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, String skuNo, Integer packageCount, Integer unitsPerCarton, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, - String shipmentId) { + String shipmentId, String packingGroupNo) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { this(cargoId, orderId, itemNo, cargoName, null, - packageCount, null, lengthCm, widthCm, heightCm, null); + packageCount, null, lengthCm, widthCm, heightCm, null, null); } public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, String shipmentId) { this(cargoId, orderId, itemNo, cargoName, skuNo, - packageCount, null, lengthCm, widthCm, heightCm, shipmentId); + packageCount, null, lengthCm, widthCm, heightCm, shipmentId, null); } } } 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 923ba2e5c..1275279de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -26,6 +26,12 @@ public interface PrintPdfClient { @RequestHeader(value = "X-Request-Id", required = false) String requestId, @RequestBody FreightA4PdfRequest request); + @PostMapping(value = "/dropship-pallet-a4-documents", consumes = "application/json") + ResponseEntity generateDropshipPalletA4Documents( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody DropshipPalletA4PdfRequest request); + record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List