This commit is contained in:
2026-09-16 11:06:33 +08:00
parent a4c659f4c6
commit a098c4377c
46 changed files with 356 additions and 109 deletions
@@ -17124,6 +17124,7 @@ USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
trucking_scope VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空',
region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址',
@@ -17132,7 +17133,8 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region),
UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,trucking_scope,status,region),
CONSTRAINT chk_wh_supplier_trucking_scope CHECK ((category = 'TRUCKING' AND trucking_scope IS NOT NULL AND trucking_scope IN ('DOMESTIC','OVERSEAS')) OR (category <> 'TRUCKING' AND trucking_scope IS NULL)),
CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
@@ -17163,7 +17165,7 @@ CALL supplier_add_column_if_missing('deleted',"TINYINT NOT NULL DEFAULT 0 COMMEN
CALL supplier_add_column_if_missing('version',"INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name(tenant_id,name)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter(tenant_id,category,status,region)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter(tenant_id,category,trucking_scope,status,region)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64),IN p_expression TEXT)
+3 -3
View File
@@ -47,7 +47,7 @@
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
@@ -69,13 +69,13 @@
- 一件代发以 `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`、数据库快照字段和对应契约测试。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
@@ -23,7 +23,7 @@ public interface WarehouseSupplierClient {
@RequestParam String carrierCode);
record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
List<String> carrierCodes, String truckingScope) {}
@GetMapping("/{supplierId}/trucking-contact")
ApiResponse<TruckingContact> truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
@@ -92,12 +92,17 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer inboundFragmentCount,Integer fullPackageCount,List<Long> memberInboundIds) {
public PlanCoLoadGroup { memberInboundIds=memberInboundIds==null?List.of():List.copyOf(memberInboundIds); }
}
public record PlanPalletMembers(Long palletId,List<GroupMemberCandidate> members) {
public PlanPalletMembers { members=members==null?List.of():List.copyOf(members); }
}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<PlanCoLoadGroup> coLoadGroups,
List<PlanPalletMembers> palletMembers,
List<LoadingPlanItemEntity> items) {
public Plan {
coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups);
palletMembers=palletMembers==null?List.of():List.copyOf(palletMembers);
items=items==null?List.of():List.copyOf(items);
}
}
@@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@@ -282,6 +283,21 @@ public interface DropshipPalletizationMapper {
ORDER BY o.inbound_code,o.id
""") List<GroupMemberCandidate> palletInboundMembers(@Param("palletId") long palletId);
@Select("""
<script>
SELECT c.pallet_id palletId,o.tenant_id accountId,o.id inboundId,o.order_id orderId,o.order_no orderNo,
o.inbound_code inboundCode,o.customer_name customerName,COUNT(DISTINCT c.id) availablePackageCount,
o.process_status orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
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 IN
<foreach collection="palletIds" item="palletId" open="(" separator="," close=")">#{palletId}</foreach>
GROUP BY c.pallet_id,o.tenant_id,o.id,o.order_id,o.order_no,o.inbound_code,o.customer_name,
o.process_status,o.expected_arrival_at
ORDER BY c.pallet_id,o.inbound_code,o.id
</script>
""") List<PlanPalletMemberRow> palletInboundMembersByPalletIds(@Param("palletIds") Collection<Long> palletIds);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
@@ -318,6 +334,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,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 PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@@ -149,7 +149,7 @@ public class ContainerService {
validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
var trucking = suppliers.overseasTrucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -1663,7 +1663,22 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),collapsePalletUnits(items));
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),planPalletMembers(items),
collapsePalletUnits(items));
}
private List<PlanPalletMembers> planPalletMembers(List<LoadingPlanItemEntity> items) {
List<Long> palletIds=items.stream().map(LoadingPlanItemEntity::getPalletId).filter(Objects::nonNull)
.distinct().toList();
if(palletIds.isEmpty()) return List.of();
return dropshipPallets.palletInboundMembersByPalletIds(palletIds).stream()
.collect(java.util.stream.Collectors.groupingBy(
com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow::getPalletId,
LinkedHashMap::new,java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry->new PlanPalletMembers(entry.getKey(),entry.getValue().stream()
.map(row->new GroupMemberCandidate(row.getAccountId(),row.getInboundId(),row.getOrderId(),
row.getOrderNo(),row.getInboundCode(),row.getCustomerName(),row.getAvailablePackageCount(),
row.getOrderStatusSnapshot(),row.getExpectedArrivalAt()))
.toList())).toList();
}
private List<LoadingPlanItemEntity> collapsePalletUnits(List<LoadingPlanItemEntity> items) {
List<LoadingPlanItemEntity> result = new ArrayList<>();
@@ -34,7 +34,7 @@ public class TruckAppointmentFeeService {
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
throw conflict("国内拖车费项目不存在、已停用或未启用应付");
}
suppliers.trucking(appointment.getCarrierId(), null);
suppliers.domesticTrucking(appointment.getCarrierId(), null);
List<Share> shares = allocate(total, rows);
Map<Long, List<Share>> plans = new LinkedHashMap<>();
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share));
@@ -47,8 +47,8 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.domesticTrucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.domesticTrucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
@Transactional
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){
String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
@@ -31,8 +31,21 @@ public class WarehouseSupplierSnapshotService {
}
}
public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) {
return resolve(supplierId, suppliedName, "TRUCKING");
public WarehouseSupplierClient.Snapshot domesticTrucking(Long supplierId, String suppliedName) {
return trucking(supplierId, suppliedName, "DOMESTIC");
}
public WarehouseSupplierClient.Snapshot overseasTrucking(Long supplierId, String suppliedName) {
return trucking(supplierId, suppliedName, "OVERSEAS");
}
private WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName, String scope) {
var snapshot = resolve(supplierId, suppliedName, "TRUCKING");
if (snapshot != null && !scope.equals(snapshot.truckingScope())) {
throw new BizException(ResultCode.CONFLICT, "DOMESTIC".equals(scope)
? "国内拖车只能使用国内拖车供应商" : "清关提柜只能使用海外车队");
}
return snapshot;
}
public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) {
@@ -96,7 +96,7 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
var fleet = suppliers.overseasTrucking(request.fleetSupplierId(), request.fleetSupplierName());
if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
@@ -1622,7 +1622,6 @@ 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}
@@ -2445,19 +2444,20 @@ components:
groupMembers:
type: array
description: 同柜组或封托托盘的完整入仓成员;SEALED_PALLET 行按托盘唯一分页,成员 availablePackageCount 为该入仓码在本托的箱数,行 availablePackageCount 为整托总箱数。搜索命中任一成员仍返回整托完整组成;普通箱件候选为空数组。
items:
type: object
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string}
customerName: {type: string}
availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
items: {$ref: '#/components/schemas/SmartLoadingCandidateGroupMember'}
SmartLoadingCandidateGroupMember:
type: object
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string}
customerName: {type: string}
availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
SmartLoadingWorkbenchContainerPageEnvelope:
type: object
required: [code, message, data]
@@ -2496,7 +2496,7 @@ components:
version: {type: integer, minimum: 0, description: 柜次版本}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
properties:
id: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
@@ -2516,6 +2516,10 @@ components:
type: array
description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建
items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'}
palletMembers:
type: array
description: 当前计划内每个整托按入仓码汇总的实际装柜箱数;用于一托一行时展示完整组成
items: {$ref: '#/components/schemas/SmartLoadingPlanPalletMembers'}
items:
type: array
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
@@ -2534,6 +2538,15 @@ components:
minItems: 1
uniqueItems: true
items: {type: string, pattern: '^[1-9][0-9]*$'}
SmartLoadingPlanPalletMembers:
type: object
required: [palletId, members]
properties:
palletId: {type: string, pattern: '^[1-9][0-9]*$'}
members:
type: array
minItems: 1
items: {$ref: '#/components/schemas/SmartLoadingCandidateGroupMember'}
SmartLoadingPlanItem:
type: object
required: [id, packageCount, totalPackageCount, remainingPackageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
@@ -109,7 +109,7 @@ components:
required: [expectedVersion, fleetSupplierId]
properties:
expectedVersion: {type: integer, minimum: 0}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING且truckingScope=OVERSEAS的海外车队供应商ID}
fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准}
reason: {type: string, maxLength: 500, nullable: true}
PickupAppointmentRequest:
@@ -223,7 +223,7 @@ components:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
plannedLoadingAt: {type: string, format: date-time}
@@ -92,7 +92,7 @@ class OceanBookingServiceTest {
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
stubMasterData();
when(suppliers.booking(81L, "订舱代理")).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"), null));
var tree = objectMapper.valueToTree(completeDraft());
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
@@ -243,7 +243,7 @@ class OceanBookingServiceTest {
stubMasterData();
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"), null));
assertThatThrownBy(() -> service.create(9L, "admin", "agent-mismatch",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司");
@@ -253,8 +253,8 @@ class OceanBookingServiceTest {
@Test
void bookingAgentCandidatesAreFilteredByCarrierCode() {
when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of(
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON")),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"))));
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON"), null),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"), null)));
assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name)
.containsExactly("匹配代理");
@@ -268,7 +268,7 @@ class OceanBookingServiceTest {
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"), null));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-agent-mismatch",
new ReleaseRequest(3, "确认")))
@@ -392,7 +392,7 @@ class OceanBookingServiceTest {
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"), null));
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER",
"MATSON", List.of("CARRIER"), "ACTIVE")));
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.Collection;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
@@ -34,6 +35,12 @@ class SmartLoadingCandidateMapperContractTest {
.contains("WHERE c.pallet_id=#{palletId}")
.contains("GROUP BY o.tenant_id,o.id")
.doesNotContain("#{inboundId}");
String planMembers = String.join(" ", com.baiyue.firstleg.mapper.DropshipPalletizationMapper.class
.getDeclaredMethod("palletInboundMembersByPalletIds", Collection.class)
.getAnnotation(Select.class).value());
assertThat(planMembers).contains("COUNT(DISTINCT c.id) availablePackageCount")
.contains("<foreach collection=\"palletIds\"")
.contains("GROUP BY c.pallet_id,o.tenant_id,o.id");
}
@Test
void candidateHasOnlyTheCanonicalMybatisConstructor() {
@@ -220,7 +220,7 @@ class ContainerFeeDetailServiceTest {
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of()));
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC"));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
@@ -57,11 +57,11 @@ class ContainerServiceTest {
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of(), List.of()));
"NOT_STARTED", null, null, null, List.of(), List.of(), List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
}
@Test
@@ -159,6 +159,40 @@ class SmartLoadingServiceTest {
verify(coloadGroups,never()).findByInbound(anyLong(),anyLong());
}
@Test
void returnsEveryInboundCompositionWhenAPlanCollapsesASealedPalletToOneRow() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
LoadingPlanItemEntity first=item(101L,1L,201L,6);
first.setInboundId(180L); first.setOrderId(280L); first.setOrderNo("O180"); first.setInboundCode("I180");
first.setCustomerName("客户"); first.setPalletId(801L); first.setPalletNo("DP-0025"); first.setUnitType("SEALED_PALLET");
LoadingPlanItemEntity second=item(102L,1L,202L,5);
second.setInboundId(181L); second.setOrderId(281L); second.setOrderNo("O181"); second.setInboundCode("I181");
second.setCustomerName("客户"); second.setPalletId(801L); second.setPalletNo("DP-0025"); second.setUnitType("SEALED_PALLET");
when(repository.items(1L)).thenReturn(List.of(first,second));
var firstMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow();
firstMember.setPalletId(801L); firstMember.setAccountId(1L); firstMember.setInboundId(180L);
firstMember.setOrderId(280L); firstMember.setOrderNo("O180"); firstMember.setInboundCode("I180");
firstMember.setCustomerName("客户"); firstMember.setAvailablePackageCount(6);
var secondMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow();
secondMember.setPalletId(801L); secondMember.setAccountId(1L); secondMember.setInboundId(181L);
secondMember.setOrderId(281L); secondMember.setOrderNo("O181"); secondMember.setInboundCode("I181");
secondMember.setCustomerName("客户"); secondMember.setAvailablePackageCount(5);
when(dropshipPallets.palletInboundMembersByPalletIds(List.of(801L)))
.thenReturn(List.of(firstMember,secondMember));
Plan result=service.detail(1L);
assertThat(result.items()).singleElement().satisfies(row->assertThat(row.getPackageCount()).isEqualTo(11));
assertThat(result.palletMembers()).singleElement().satisfies(pallet->{
assertThat(pallet.palletId()).isEqualTo(801L);
assertThat(pallet.members()).extracting(GroupMemberCandidate::inboundCode).containsExactly("I180","I181");
assertThat(pallet.members()).extracting(GroupMemberCandidate::availablePackageCount).containsExactly(6,5);
});
verify(dropshipPallets).palletInboundMembersByPalletIds(List.of(801L));
}
@Test
void restoresLoadingPlanForContinuedAssignment() {
LoadingPlanEntity plan = plan("LOADING", 3);
@@ -59,7 +59,7 @@ class TruckAppointmentFeeServiceTest {
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100");
verify(suppliers).trucking(8L, null);
verify(suppliers).domesticTrucking(8L, null);
verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
}
@@ -43,8 +43,8 @@ class TruckAppointmentPayableFlowTest {
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
when(items.findItem(108L)).thenReturn(item);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of());
when(suppliers.trucking(8L, null)).thenReturn(supplier);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC");
when(suppliers.domesticTrucking(8L, null)).thenReturn(supplier);
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
@@ -58,8 +58,8 @@ class TruckAppointmentServiceTest {
return null;
}).when(repository).insert(any());
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
when(suppliers.domesticTrucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking();
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
@@ -536,7 +536,7 @@ class TruckAppointmentServiceTest {
@Test void createRejectsInvalidTruckingSupplierWithoutWriting() {
SaveRequest request = validRequest();
when(suppliers.trucking(8L, null)).thenThrow(
when(suppliers.domesticTrucking(8L, null)).thenThrow(
new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用"));
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -28,7 +28,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void rejectsFreeTextWithoutSupplierId() {
assertThatThrownBy(() -> service.trucking(null, "任意承运商"))
assertThatThrownBy(() -> service.domesticTrucking(null, "任意承运商"))
.isInstanceOf(BizException.class).hasMessageContaining("自由文本");
verifyNoInteractions(client);
}
@@ -36,7 +36,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"))));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"), null)));
var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING");
@@ -46,7 +46,7 @@ class WarehouseSupplierSnapshotServiceTest {
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of())));
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of(), null)));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
@@ -55,7 +55,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of())));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of(), null)));
assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
@@ -64,9 +64,29 @@ class WarehouseSupplierSnapshotServiceTest {
void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of())));
7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC")));
assertThatThrownBy(() -> service.booking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
}
@Test void truckingScopesAreMutuallyExclusiveAndMissingScopeIsRejected() {
for (String scope : new String[] {"DOMESTIC", "OVERSEAS", null}) {
when(client.get("internal-token", 7L, true, "TRUCKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "车队", "TRUCKING", "ACTIVE", java.util.List.of(), scope)));
if ("DOMESTIC".equals(scope)) {
assertThat(service.domesticTrucking(7L, null).name()).isEqualTo("车队");
} else {
assertThatThrownBy(() -> service.domesticTrucking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("国内拖车");
}
if ("OVERSEAS".equals(scope)) {
assertThat(service.overseasTrucking(7L, null).name()).isEqualTo("车队");
} else {
assertThatThrownBy(() -> service.overseasTrucking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("海外车队");
}
}
}
}
@@ -72,7 +72,7 @@ class TerminalDrayageServiceTest {
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("货柜实际到达目的港后才能安排码头车队");
verify(suppliers, never()).trucking(anyLong(), any());
verify(suppliers, never()).overseasTrucking(anyLong(), any());
}
@Test
@@ -82,8 +82,8 @@ class TerminalDrayageServiceTest {
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(mapper.operation(11L, "fleet-1")).thenReturn(null);
when(suppliers.trucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
TerminalDrayageEntity value = invocation.getArgument(0);
@@ -256,8 +256,8 @@ class TerminalDrayageServiceTest {
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(suppliers.trucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
AtomicReference<Operation> operation = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
@@ -279,7 +279,7 @@ class TerminalDrayageServiceTest {
assertThat(replay).isEqualTo(first);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
verify(suppliers, times(1)).trucking(91L, null);
verify(suppliers, times(1)).overseasTrucking(91L, null);
}
@Test
@@ -317,7 +317,7 @@ class TerminalDrayageServiceTest {
void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
when(mapper.lock(11L)).thenReturn(row(3));
when(suppliers.trucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of()));
when(suppliers.overseasTrucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS"));
service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
verifyNoInteractions(mails);
verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
@@ -332,7 +332,7 @@ class TerminalDrayageServiceTest {
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1);
when(suppliers.trucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of()));
when(suppliers.overseasTrucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS"));
service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
var ordered = org.mockito.Mockito.inOrder(mails,mapper);
ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L);
+2 -2
View File
@@ -178,7 +178,7 @@ export async function run(context) {
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
@@ -188,7 +188,7 @@ export async function run(context) {
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
@@ -37,6 +37,7 @@ export function buildSupplierBody(marker, category) {
return {
name: `财务验收供应商-${suffix}`,
category,
truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null,
...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}),
region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人',
phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`,
@@ -120,9 +120,9 @@ function truckPayload(context, oceanBooking, carrier, index) {
async function createTruck(context, oceanBooking, index) {
const carrierResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20')
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=DOMESTIC&page=1&size=20')
const carrierPage = context.apiSuccess(MODULE, `读取拖车状态样本 ${index} 的 ACTIVE 承运商`, carrierResponse, '', OPTIONS)
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name)
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name && item?.truckingScope === 'DOMESTIC')
if (!carrier) throw new Error(`创建拖车样本 ${index} 缺少 ACTIVE 承运商`)
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index))
const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS)
@@ -32,7 +32,7 @@ test('three real command payloads mutate contract fields and preserve remarks an
addStep(_module, name, _response, passed) { assert.ok(passed, name) },
addCoverageGap() { assert.fail('unexpected missing fixture') },
async request(method, path, body, _token, headers) {
if (path.includes('/suppliers?')) return response({ records: [{ id: '9223372036854775804', name: 'Offline fleet', status: 'ACTIVE' }] })
if (path.includes('/suppliers?')) return response({ records: [{ id: '9223372036854775804', name: 'Offline fleet', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] })
if (method === 'GET') {
if (path.endsWith('/history')) return response(history)
if (path.includes('?containerBookingIds=')) return response([state])
@@ -77,10 +77,10 @@ export async function prepareTerminalDrayage(context, producer) {
if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100')
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE')
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=OVERSEAS&page=1&size=100')
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE' && item?.category === 'TRUCKING' && item?.truckingScope === 'OVERSEAS')
if (!fleet) {
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response)
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING + OVERSEAS公共供应商;脚本不临时创建主数据', suppliers.response)
return null
}
const selected = await command(context, target, '选择车队', 'fleet', {
@@ -75,9 +75,9 @@ export async function run(context) {
id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED',
`oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS)
const carrierResponse = await requestWithTransientRetry(context, 'GET',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20')
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=DOMESTIC&page=1&size=20')
const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS)
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name)
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name && item?.truckingScope === 'DOMESTIC')
context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse,
Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
if (!carrier) return
@@ -79,13 +79,14 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
}
async function activeSupplier(context, category, suffix) {
const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : ''
const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : ''
const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`)
const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE'
&& (category !== 'TRUCKING' || item?.truckingScope === 'DOMESTIC')
&& (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON')))
if (existing) return { supplierId: id(existing.id), supplierName: existing.name }
const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [],
name: `${category}-验收-${suffix}`, category, truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [],
region: '深圳', legalRepresentative: '验收法人', contactName: '验收联系人', phone: '13800000000',
email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64),
licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
+1 -1
View File
@@ -21,7 +21,7 @@
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
| 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` |
| 海外仓阶梯费用 | `controller/PlatformOverseasStorageFeeController.java`、`service/OverseasStorageFeeService.java` |
| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;契约见 `src/main/resources/openapi/supplier-v1.yaml` `controller/InternalSupplierController.java` 的 `/{supplierId}/trucking-contact` 为 Firstleg 提供平台 TRUCKING 邮箱资料,取消旧请求允许读取已停用车队邮箱,仍验证内部令牌及平台归属。 |
| 平台公共供应商 | `controller/PlatformSupplierController.java`、`service/SupplierService.java`;TRUCKING 必须设置互斥 `truckingScope=DOMESTIC(国内拖车)/OVERSEAS(海外车队)`,其他类别为空;分页/导出支持类型筛选,导入模板版本3必填类型;契约见 `src/main/resources/openapi/supplier-v1.yaml` `controller/InternalSupplierController.java` 的 `/{supplierId}/trucking-contact` 为 Firstleg 提供平台 TRUCKING + OVERSEAS 海外车队邮箱资料,取消旧请求允许读取已停用车队邮箱,仍验证内部令牌及平台归属。 |
| 国内整柜业务 | `domesticcontainer/controller/PlatformDomesticContainerController.java`、`domesticcontainer/service/DomesticContainerService.java`;契约见 `src/main/resources/openapi/domestic-container-v1.yaml` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
@@ -44,7 +44,10 @@ public class InternalSupplierController {
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) {
requireToken(token);
service.internalSnapshot(supplierId, requireActive, "TRUCKING");
var snapshot = service.internalSnapshot(supplierId, requireActive, "TRUCKING");
if (!"OVERSEAS".equals(snapshot.truckingScope())) {
throw new BizException(ResultCode.CONFLICT, "清关提柜只能使用海外车队");
}
var supplier = service.detail(supplierId);
return ApiResponse.success(new TruckingContact(supplierId, supplier.name(), supplier.email()));
}
@@ -38,7 +38,7 @@ public class PlatformSupplierController {
@GetMapping("/import-template") @PreAuthorize("hasAuthority('platform:supplier:create')")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment().filename("supplier-import-template-v2.xlsx", StandardCharsets.UTF_8).build());
headers.setContentDisposition(ContentDisposition.attachment().filename("supplier-import-template-v3.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(transferService.template());
}
@@ -19,7 +19,8 @@ public final class SupplierDtos {
@Size(max = 80) String region,
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
String keyword,
@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String carrierCode) {
@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String carrierCode,
@Pattern(regexp = "DOMESTIC|OVERSEAS") String truckingScope) {
public PageQuery {
page = page == null ? 1 : page;
size = size == null ? 20 : size;
@@ -40,7 +41,8 @@ public final class SupplierDtos {
@Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status,
@NotBlank @Pattern(regexp = "MONTHLY_30|MONTHLY_45|MONTHLY_60|MONTHLY_90|PER_TRANSACTION|IMMEDIATE") String settlementPeriod,
@Size(max = 500) String remark) {}
@Size(max = 500) String remark,
@Pattern(regexp = "DOMESTIC|OVERSEAS") String truckingScope) {}
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
@@ -51,9 +53,9 @@ public final class SupplierDtos {
String businessLicenseNo, String licenseFileId, String status,
String settlementPeriod, String remark, Long createdBy,
LocalDateTime createdAt, Long updatedBy, LocalDateTime updatedAt,
Boolean deleted, Integer version) {}
Boolean deleted, Integer version, String truckingScope) {}
public record InternalSnapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
List<String> carrierCodes, String truckingScope) {}
public record ImportError(int row, String field, String message) {}
public record ImportResult(String fileName, int totalRows, int importedRows, boolean success,
List<ImportError> errors) {}
@@ -19,6 +19,8 @@ public class SupplierEntity extends BaseEntity {
private Long tenantId;
private String name;
private String category;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String truckingScope;
private String region;
private String legalRepresentative;
private String contactName;
@@ -25,6 +25,7 @@ public class SupplierRepository {
}
LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category())
.eq(StringUtils.hasText(query.truckingScope()), SupplierEntity::getTruckingScope, query.truckingScope())
.eq(StringUtils.hasText(query.region()), SupplierEntity::getRegion, query.region())
.eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.carrierCode()), SupplierEntity::getCategory, "BOOKING")
@@ -32,11 +32,11 @@ import java.util.Set;
public class SupplierImportExportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "2";
private static final String TEMPLATE_VERSION = "3";
private static final Set<String> CATEGORIES = Set.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
private static final Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
private static final String[] HEADERS = {"供应商名称*", "供应商类别*", "船司标准编码(多值)", "地区*", "法人代表*",
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注"};
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注", "拖车类型(TRUCKING必填)"};
@Resource private SupplierService supplierService;
@Resource private SupplierRepository repository;
@@ -47,7 +47,7 @@ public class SupplierImportExportService {
XSSFSheet sheet = workbook.createSheet("供应商");
Row version = sheet.createRow(0); version.createCell(0).setCellValue("模板版本"); version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue("BOOKING至少填写一个船司标准编码,多个编码使用英文逗号分隔;非BOOKING必须留空");
instruction.createCell(1).setCellValue("BOOKING至少填写一个船司标准编码,多个编码使用英文逗号分隔;非BOOKING的船司编码必须留空;TRUCKING的拖车类型必填DOMESTIC(国内拖车)或OVERSEAS(海外车队),其他类别留空");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); sheet.setColumnWidth(i, 22 * 256); }
workbook.write(output); return output.toByteArray();
@@ -87,10 +87,10 @@ public class SupplierImportExportService {
}
public byte[] exportCsv(PageQuery query) {
StringBuilder csv = new StringBuilder("\uFEFF供应商ID,供应商名称,供应商类别,船司标准编码,地区,法人代表,联系人,联系电话,电子邮箱,公司地址,营业执照号,营业执照文件ID,状态,结算周期,备注\r\n");
StringBuilder csv = new StringBuilder("\uFEFF供应商ID,供应商名称,供应商类别,船司标准编码,地区,法人代表,联系人,联系电话,电子邮箱,公司地址,营业执照号,营业执照文件ID,状态,结算周期,备注,拖车类型\r\n");
int page = 1;
while (true) {
var result = supplierService.page(new PageQuery(page, 200, query.category(), query.region(), query.status(), query.keyword(), query.carrierCode()));
var result = supplierService.page(new PageQuery(page, 200, query.category(), query.region(), query.status(), query.keyword(), query.carrierCode(), query.truckingScope()));
for (SupplierResource value : result.records()) csv.append(row(value));
if (page >= result.pages()) break;
page++;
@@ -103,6 +103,11 @@ public class SupplierImportExportService {
errors.add(new ImportError(row.number(), violation.getPropertyPath().toString(), violation.getMessage()));
}
if (!CATEGORIES.contains(row.request().category())) errors.add(new ImportError(row.number(), "category", "供应商类别无效"));
try {
SupplierService.validateTruckingScope(row.request().category(), row.request().truckingScope());
} catch (com.baiyue.common.core.exception.BizException exception) {
errors.add(new ImportError(row.number(), "truckingScope", exception.getMessage()));
}
boolean booking = "BOOKING".equals(row.request().category());
if (booking && row.request().carrierCodes().isEmpty()) errors.add(new ImportError(row.number(), "carrierCodes", "BOOKING至少关联一个船司编码"));
if (!booking && !row.request().carrierCodes().isEmpty()) errors.add(new ImportError(row.number(), "carrierCodes", "非BOOKING必须留空"));
@@ -117,7 +122,7 @@ public class SupplierImportExportService {
value(row, 5, formatter), value(row, 6, formatter), nullable(value(row, 7, formatter)),
nullable(value(row, 8, formatter)), nullable(value(row, 9, formatter)), nullable(value(row, 10, formatter)),
value(row, 11, formatter).toUpperCase(Locale.ROOT), value(row, 12, formatter).toUpperCase(Locale.ROOT),
nullable(value(row, 13, formatter)));
nullable(value(row, 13, formatter)), nullable(value(row, 14, formatter).toUpperCase(Locale.ROOT)));
}
private static List<String> carrierCodes(String value) {
LinkedHashSet<String> result = new LinkedHashSet<>();
@@ -127,7 +132,7 @@ public class SupplierImportExportService {
private static String row(SupplierResource v) {
return List.of(Long.toString(v.id()), v.name(), v.category(), String.join("|", v.carrierCodes()), v.region(),
v.legalRepresentative(), v.contactName(), v.phone(), text(v.email()), text(v.address()),
text(v.businessLicenseNo()), text(v.licenseFileId()), v.status(), v.settlementPeriod(), text(v.remark()))
text(v.businessLicenseNo()), text(v.licenseFileId()), v.status(), v.settlementPeriod(), text(v.remark()), text(v.truckingScope()))
.stream().map(SupplierImportExportService::csv).collect(java.util.stream.Collectors.joining(",")) + "\r\n";
}
private static String csv(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; }
@@ -40,6 +40,7 @@ public class SupplierService {
@Transactional
@BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#id", description = "新增供应商", captureBefore = false)
public Long create(long id, SaveRequest request) {
validateTruckingScope(request.category(), request.truckingScope());
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
ensureName(request.name(), null);
SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request);
@@ -53,6 +54,7 @@ public class SupplierService {
@Transactional
@BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商")
public void update(long id, SaveRequest request) {
validateTruckingScope(request.category(), request.truckingScope());
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request);
try {
@@ -74,9 +76,9 @@ public class SupplierService {
public List<InternalSnapshot> internalBookingCandidates(String carrierCode) {
return page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null,
normalizeCarrierCode(carrierCode))).records().stream()
normalizeCarrierCode(carrierCode), null)).records().stream()
.map(value -> new InternalSnapshot(value.id(), value.name(), value.category(), value.status(),
value.carrierCodes())).toList();
value.carrierCodes(), value.truckingScope())).toList();
}
private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; }
@@ -84,6 +86,16 @@ public class SupplierService {
private List<String> carrierCodes(long id) {
return repository.carrierCodes(PUBLIC_TENANT_ID, List.of(id)).getOrDefault(id, List.of());
}
public static void validateTruckingScope(String category, String scope) {
if ("TRUCKING".equals(category)) {
if (!"DOMESTIC".equals(scope) && !"OVERSEAS".equals(scope)) {
throw new BizException(ResultCode.BAD_REQUEST, "拖车供应商必须选择国内拖车或海外车队");
}
} else if (scope != null) {
throw new BizException(ResultCode.BAD_REQUEST, "非拖车供应商不能设置拖车类型");
}
}
private static List<String> normalizeCarrierCodes(String category, List<String> values) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (values != null) values.forEach(value -> {
@@ -110,14 +122,14 @@ public class SupplierService {
}
private static InternalSnapshot snapshot(SupplierEntity e, List<String> carrierCodes) {
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus(),
List.copyOf(carrierCodes));
List.copyOf(carrierCodes), e.getTruckingScope());
}
private static SupplierResource resource(SupplierEntity e, List<String> carrierCodes) {
return new SupplierResource(e.getId(), e.getTenantId(), e.getName(), e.getCategory(),
List.copyOf(carrierCodes), e.getRegion(), e.getLegalRepresentative(), e.getContactName(),
e.getPhone(), e.getEmail(), e.getAddress(), e.getBusinessLicenseNo(), e.getLicenseFileId(),
e.getStatus(), e.getSettlementPeriod(), e.getRemark(), e.getCreatedBy(), e.getCreatedAt(),
e.getUpdatedBy(), e.getUpdatedAt(), e.getDeleted(), e.getVersion());
e.getUpdatedBy(), e.getUpdatedAt(), e.getDeleted(), e.getVersion(), e.getTruckingScope());
}
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); }
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setTruckingScope(r.truckingScope()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); }
}
@@ -1,4 +1,4 @@
-- 平台公共供应商业务档案。可用于空库初始化,也可重复执行升级存量结构。
-- 平台公共供应商最新初始化基线。拖车类型必填,不推断或回填测试存量。
USE baiyue_warehouse;
SET NAMES utf8mb4;
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称',
category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
trucking_scope VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空',
region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表',
contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
@@ -26,7 +27,8 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_wh_supplier_name (tenant_id,name),
KEY idx_wh_supplier_filter (tenant_id,category,status,region),
KEY idx_wh_supplier_filter (tenant_id,category,trucking_scope,status,region),
CONSTRAINT chk_wh_supplier_trucking_scope CHECK ((category = 'TRUCKING' AND trucking_scope IS NOT NULL AND trucking_scope IN ('DOMESTIC','OVERSEAS')) OR (category <> 'TRUCKING' AND trucking_scope IS NULL)),
CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0),
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')),
@@ -67,7 +69,7 @@ DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name (tenant_id,name)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,status,region)');
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,trucking_scope,status,region)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
@@ -95,7 +97,7 @@ BEGIN
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND index_name IN ('PRIMARY','idx_wh_supplier_carrier_code')) <> 2
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_carrier_tenant','chk_wh_supplier_carrier_code')) <> 2
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_status','chk_wh_supplier_settlement','chk_wh_supplier_trucking_scope')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
END IF;
END$$
@@ -48,6 +48,7 @@
| `page` | integer | 否 | 默认 `1`,最小 `1` |
| `size` | integer | 否 | 默认 `20`,范围 `1..200` |
| `category` | enum | 否 | 供应商分类 |
| `truckingScope` | enum | 否 | DOMESTIC 国内拖车或 OVERSEAS 海外车队;拖车业务候选必须按对应类型筛选 |
| `region` | string | 否 | 地区精确匹配,最长 80 |
| `status` | enum | 否 | 合作状态 |
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 |
@@ -119,7 +120,7 @@
}
```
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 必须提交至少一个去重后的 `carrierCodes`;非 `BOOKING` 可不提交该字段或提交空数组,提交非空数组返回 `A0400`。订舱供应商不区分国内或国外。
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 必须提交至少一个去重后的 `carrierCodes`;非 `BOOKING` 可不提交该字段或提交空数组,提交非空数组返回 `A0400`。订舱供应商不区分国内或国外。拖车供应商必须单选 `truckingScope=DOMESTIC`(国内拖车)或 `OVERSEAS`(海外车队),非拖车类别为空。列表、详情和内部快照返回该字段,国内拖车预约仅可用 DOMESTIC,清关提柜仅可用 OVERSEAS;导入模板版本3的“拖车类型(TRUCKING必填)”列填写相同枚举。
Admin 订舱供应商候选固定复用分页接口:`GET /api/platform/v1/suppliers?category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200`,权限为 `platform:supplier:list`,响应为 `PageResult<Supplier>`。服务端完成关联过滤,前端不得先拉全量再自行过滤。
@@ -11,6 +11,7 @@ paths:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
- { name: truckingScope, in: query, schema: { type: string, enum: [DOMESTIC, OVERSEAS] }, description: 国内拖车或海外车队 }
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
@@ -32,7 +33,7 @@ paths:
/api/platform/v1/suppliers/import:
post:
summary: 原子批量导入供应商
description: 权限 platform:supplier:create;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。
description: 权限 platform:supplier:create;使用版本3模板,TRUCKING必须填写DOMESTIC或OVERSEAS拖车类型,其他类别留空;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
@@ -45,6 +46,7 @@ paths:
parameters:
- { $ref: '#/components/parameters/TenantId' }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
- { name: truckingScope, in: query, schema: { type: string, enum: [DOMESTIC, OVERSEAS] }, description: 国内拖车或海外车队 }
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, schema: { type: string } }
@@ -84,7 +86,7 @@ paths:
/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact:
get:
summary: 内部查询码头车队收件资料
description: 固定校验TRUCKING类别与平台归属;取消旧车队请求允许requireActive=false。
description: 固定校验TRUCKING类别、OVERSEAS拖车类型与平台归属;取消旧车队请求允许requireActive=false。
security: [{internalServiceToken: []}]
parameters:
- {name: supplierId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -134,6 +136,7 @@ components:
RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } }
schemas:
Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] }
TruckingScope: { type: string, nullable: true, enum: [DOMESTIC, OVERSEAS], description: TRUCKING必选国内拖车DOMESTIC或海外车队OVERSEAS;非TRUCKING必须为空,不支持兼营 }
CarrierCode: { type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] }
CarrierCodes: { type: array, uniqueItems: true, maxItems: 7, items: { $ref: '#/components/schemas/CarrierCode' }, description: BOOKING必填且至少一个;非BOOKING可省略或为空,提交非空数组返回A0400 }
Status: { type: string, enum: [ACTIVE, INACTIVE] }
@@ -143,6 +146,7 @@ components:
properties:
name: { type: string, maxLength: 160 }
category: { $ref: '#/components/schemas/Category' }
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
region: { type: string, maxLength: 80 }
legalRepresentative: { type: string, maxLength: 80 }
@@ -202,6 +206,7 @@ components:
supplierId: { type: integer, format: int64 }
name: { type: string }
category: { $ref: '#/components/schemas/Category' }
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
status: { $ref: '#/components/schemas/Status' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
InternalSnapshotResponse:
@@ -20,8 +20,15 @@ class InternalSupplierContactTest {
var supplier = mock(SupplierResource.class);
when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com");
when(service.detail(1L)).thenReturn(supplier);
when(service.internalSnapshot(1L,false,"TRUCKING")).thenReturn(
new com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot(1L,"Fleet","TRUCKING","ACTIVE",java.util.List.of(),"OVERSEAS"));
var result = controller.truckingContact("test-token",1L,false).data();
assertThat(result.email()).isEqualTo("fleet@example.com");
verify(service).internalSnapshot(1L,false,"TRUCKING");
when(service.internalSnapshot(2L,true,"TRUCKING")).thenReturn(
new com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot(2L,"Domestic","TRUCKING","ACTIVE",java.util.List.of(),"DOMESTIC"));
assertThatThrownBy(() -> controller.truckingContact("test-token",2L,true))
.isInstanceOf(BizException.class).hasMessageContaining("海外车队");
verify(service, never()).detail(2L);
}
}
@@ -14,7 +14,7 @@ class SupplierSqlContractTest {
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0",
"chk_wh_supplier_tenant CHECK (tenant_id = 0)",
"license_file_id VARCHAR(128)",
"license_file_id VARCHAR(128)", "trucking_scope VARCHAR(16)", "chk_wh_supplier_trucking_scope", "trucking_scope IS NOT NULL", "trucking_scope IS NULL", "DOMESTIC", "OVERSEAS",
"MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE",
"created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED",
"supplier_add_column_if_missing", "uk_wh_supplier_name", "idx_wh_supplier_filter",
@@ -23,7 +23,7 @@ class SupplierSqlContractTest {
String supplierTable = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier ("),
sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier_carrier"));
assertThat(supplierTable).doesNotContain("carrier_code", "carrier_codes");
assertThat(sql).doesNotContain("scope VARCHAR", "chk_wh_supplier_scope", "DOMESTIC/INTERNATIONAL");
assertThat(sql).doesNotContain("\n scope VARCHAR", "chk_wh_supplier_scope", "DOMESTIC/INTERNATIONAL");
}
}
@@ -21,14 +21,16 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SupplierImportExportServiceTest {
@Mock private SupplierService supplierService;
@Mock private com.baiyue.warehouse.repository.SupplierRepository repository;
@InjectMocks private SupplierImportExportService service;
private PageQuery query;
@BeforeEach void setUp() { query = new PageQuery(1, 20, "BOOKING", null, "ACTIVE", null, "MATSON"); }
@BeforeEach void setUp() { query = new PageQuery(1, 20, "BOOKING", null, "ACTIVE", null, "MATSON", null);
org.springframework.test.util.ReflectionTestUtils.setField(service, "validator", jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator()); }
@Test void templateUsesMultiCarrierCodesColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(service.template()))) {
assertThat(workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue()).isEqualTo("2");
assertThat(workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue()).isEqualTo("3");
assertThat(workbook.getSheetAt(0).getRow(2).getCell(2).getStringCellValue()).isEqualTo("船司标准编码(多值)");
}
}
@@ -36,10 +38,49 @@ class SupplierImportExportServiceTest {
@Test void csvExportsCarrierCodesWithoutChangingPersistenceShape() {
SupplierResource resource = new SupplierResource(7L, 0L, "订舱代理", "BOOKING", List.of("MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null,
null, null, null, null, false, 0);
when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON")))
null, null, null, null, false, 0, null);
when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON", null)))
.thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1));
String csv = new String(service.exportCsv(query), StandardCharsets.UTF_8);
assertThat(csv).contains("船司标准编码", "\"MATSON|ZIM\"");
}
@Test void importRequiresScopeBeforeAnyRowIsWritten() throws Exception {
var invalid = service.importFile("suppliers.xlsx", truckingWorkbook(null, "OVERSEAS"));
assertThat(invalid.success()).isFalse();
assertThat(invalid.errors()).anySatisfy(error -> {
assertThat(error.row()).isEqualTo(4);
assertThat(error.field()).isEqualTo("truckingScope");
});
org.mockito.Mockito.verifyNoInteractions(supplierService);
var valid = service.importFile("suppliers.xlsx", truckingWorkbook("DOMESTIC", "OVERSEAS"));
assertThat(valid.success()).isTrue();
var captor = org.mockito.ArgumentCaptor.forClass(com.baiyue.warehouse.dto.SupplierDtos.SaveRequest.class);
org.mockito.Mockito.verify(supplierService, org.mockito.Mockito.times(2)).create(org.mockito.ArgumentMatchers.anyLong(), captor.capture());
assertThat(captor.getAllValues()).extracting(com.baiyue.warehouse.dto.SupplierDtos.SaveRequest::truckingScope)
.containsExactly("DOMESTIC", "OVERSEAS");
}
@Test void exportPreservesScopeFilterAndTypeColumn() {
var query = new PageQuery(1, 20, "TRUCKING", null, "ACTIVE", null, null, "OVERSEAS");
var resource = new SupplierResource(8L, 0L, "海外车队", "TRUCKING", List.of(),
"洛杉矶", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null,
null, null, null, null, false, 0, "OVERSEAS");
when(supplierService.page(new PageQuery(1, 200, "TRUCKING", null, "ACTIVE", null, null, "OVERSEAS")))
.thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1));
assertThat(new String(service.exportCsv(query), StandardCharsets.UTF_8)).contains("拖车类型", "\"OVERSEAS\"");
}
private byte[] truckingWorkbook(String... scopes) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(service.template()));
var output = new java.io.ByteArrayOutputStream()) {
for (int i = 0; i < scopes.length; i++) {
var row = workbook.getSheetAt(0).createRow(3 + i);
String[] values = {"车队" + i, "TRUCKING", "", "宁波", "法人", "联系人", "13800000000", "", "", "", "", "ACTIVE", "MONTHLY_30", "", scopes[i]};
for (int c = 0; c < values.length; c++) if (values[c] != null) row.createCell(c).setCellValue(values[c]);
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -29,7 +29,7 @@ class SupplierServiceTest {
@BeforeEach void setUp() {
booking = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON"), "宁波", "法人", "联系人",
"13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001",
"ACTIVE", "MONTHLY_30", null);
"ACTIVE", "MONTHLY_30", null, null);
}
@Test void createsPlatformPublicBookingSupplier() {
@@ -42,26 +42,26 @@ class SupplierServiceTest {
@Test void deduplicatesBookingCarrierCodesAndRejectsNonBookingAssociations() {
SaveRequest duplicated = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON", "MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null,
"ACTIVE", "MONTHLY_30", null);
"ACTIVE", "MONTHLY_30", null, null);
service.create(102L, duplicated);
verify(repository).replaceCarrierCodes(eq(0L), eq(102L), eq(java.util.List.of("MATSON", "ZIM")), any());
SaveRequest customs = new SaveRequest("报关行", "CUSTOMS", java.util.List.of("MATSON"), "宁波", "法人",
"联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
"联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, null);
assertThatThrownBy(() -> service.create(103L, customs)).isInstanceOf(BizException.class)
.hasMessageContaining("非BOOKING");
}
@Test void bookingRequiresAtLeastOneCarrierCode() {
SaveRequest empty = new SaveRequest("测试供应商", "BOOKING", java.util.List.of(), "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, null);
assertThatThrownBy(() -> service.create(104L, empty)).isInstanceOf(BizException.class)
.hasMessageContaining("至少关联一个");
}
@Test void nonBookingMayOmitCarrierCodes() {
SaveRequest customs = new SaveRequest("报关行", "CUSTOMS", null, "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, null);
service.create(105L, customs);
verify(repository).replaceCarrierCodes(eq(0L), eq(105L), eq(java.util.List.of()), any());
}
@@ -105,4 +105,42 @@ class SupplierServiceTest {
assertThatThrownBy(() -> service.internalSnapshot(101L, true, "CUSTOMS"))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
}
@Test void truckingRequiresExactlyOneScopeAndNonTruckingRejectsScope() {
for (String scope : new String[] {null, "", "BOTH", "DOMESTIC,OVERSEAS"}) {
assertThatThrownBy(() -> service.create(201L, trucking(scope)))
.isInstanceOf(BizException.class).hasMessageContaining("国内拖车或海外车队");
}
assertThatThrownBy(() -> SupplierService.validateTruckingScope("BOOKING", "DOMESTIC"))
.isInstanceOf(BizException.class).hasMessageContaining("非拖车");
verify(repository, never()).insert(any());
}
@Test void persistsScopeAndReturnsItInResourcesAndInternalSnapshot() {
for (String scope : java.util.List.of("DOMESTIC", "OVERSEAS")) {
long id = "DOMESTIC".equals(scope) ? 201L : 202L;
service.create(id, trucking(scope));
var captor = org.mockito.ArgumentCaptor.forClass(SupplierEntity.class);
verify(repository, org.mockito.Mockito.atLeastOnce()).insert(captor.capture());
var entity = captor.getValue();
assertThat(entity.getTruckingScope()).isEqualTo(scope);
when(repository.find(0L, id)).thenReturn(entity);
assertThat(service.detail(id).truckingScope()).isEqualTo(scope);
assertThat(service.internalSnapshot(id, true, "TRUCKING").truckingScope()).isEqualTo(scope);
}
}
@Test void changingCategoryClearsTruckingScope() {
SupplierEntity entity = new SupplierEntity(); entity.setId(201L); entity.setTenantId(0L);
entity.setCategory("TRUCKING"); entity.setTruckingScope("DOMESTIC");
when(repository.find(0L, 201L)).thenReturn(entity);
service.update(201L, booking);
assertThat(entity.getTruckingScope()).isNull();
verify(repository).save(entity);
}
private SaveRequest trucking(String scope) {
return new SaveRequest("车队", "TRUCKING", java.util.List.of(), "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, scope);
}
}