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 ( CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', 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认证', 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 '地区', region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name 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 '公司地址', 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 '创建时间', 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 '最后修改时间', 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), 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_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')), 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')) 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 '乐观锁版本'"); CALL supplier_add_column_if_missing('version',"INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing; 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='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; DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64),IN p_expression TEXT) 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` | | 海外派送完成回写 | 同一内部事件入口、消费者 `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` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `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` | | 海外仓业务国内出仓后船节点定时查询 | `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` | | 船节点跨区交接事件 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 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` 发布应付 | | 订舱级拖车与整柜出仓调度 | `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 打托实现。 - 一件代发以 `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 消费者。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`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` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 订舱列表 `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,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../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` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 - 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
@@ -23,7 +23,7 @@ public interface WarehouseSupplierClient {
@RequestParam String carrierCode); @RequestParam String carrierCode);
record Snapshot(long supplierId, String name, String category, String status, record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {} List<String> carrierCodes, String truckingScope) {}
@GetMapping("/{supplierId}/trucking-contact") @GetMapping("/{supplierId}/trucking-contact")
ApiResponse<TruckingContact> truckingContact(@RequestHeader("X-Internal-Service-Token") String token, 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) { Integer inboundFragmentCount,Integer fullPackageCount,List<Long> memberInboundIds) {
public PlanCoLoadGroup { memberInboundIds=memberInboundIds==null?List.of():List.copyOf(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, 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, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<PlanCoLoadGroup> coLoadGroups, Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<PlanCoLoadGroup> coLoadGroups,
List<PlanPalletMembers> palletMembers,
List<LoadingPlanItemEntity> items) { List<LoadingPlanItemEntity> items) {
public Plan { public Plan {
coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups); coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups);
palletMembers=palletMembers==null?List.of():List.copyOf(palletMembers);
items=items==null?List.of():List.copyOf(items); items=items==null?List.of():List.copyOf(items);
} }
} }
@@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Collection;
import java.util.List; import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */ /** 一件代发逐箱清点、待绑托与封托事实。 */
@@ -282,6 +283,21 @@ public interface DropshipPalletizationMapper {
ORDER BY o.inbound_code,o.id ORDER BY o.inbound_code,o.id
""") List<GroupMemberCandidate> palletInboundMembers(@Param("palletId") long palletId); """) 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("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count, 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 (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 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 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 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 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; } @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()); validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg()); ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); 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()); copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId()); current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -1663,7 +1663,22 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, 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) { private List<LoadingPlanItemEntity> collapsePalletUnits(List<LoadingPlanItemEntity> items) {
List<LoadingPlanItemEntity> result = new ArrayList<>(); List<LoadingPlanItemEntity> result = new ArrayList<>();
@@ -34,7 +34,7 @@ public class TruckAppointmentFeeService {
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) { if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
throw conflict("国内拖车费项目不存在、已停用或未启用应付"); throw conflict("国内拖车费项目不存在、已停用或未启用应付");
} }
suppliers.trucking(appointment.getCarrierId(), null); suppliers.domesticTrucking(appointment.getCarrierId(), null);
List<Share> shares = allocate(total, rows); List<Share> shares = allocate(total, rows);
Map<Long, List<Share>> plans = new LinkedHashMap<>(); Map<Long, List<Share>> plans = new LinkedHashMap<>();
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share)); 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 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 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 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 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.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 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 @Transactional
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){ 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); String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
@@ -31,8 +31,21 @@ public class WarehouseSupplierSnapshotService {
} }
} }
public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) { public WarehouseSupplierClient.Snapshot domesticTrucking(Long supplierId, String suppliedName) {
return resolve(supplierId, suppliedName, "TRUCKING"); 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) { public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) {
@@ -96,7 +96,7 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId); TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion()); requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队"); 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 && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) { if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId); mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
@@ -1622,7 +1622,6 @@ components:
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照} lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true} channelCode: {type: string, nullable: true}
channelName: {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 托盘码} expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码}
taxMode: {type: string, nullable: true} taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true} pricingTaxMode: {type: string, nullable: true}
@@ -2445,19 +2444,20 @@ components:
groupMembers: groupMembers:
type: array type: array
description: 同柜组或封托托盘的完整入仓成员;SEALED_PALLET 行按托盘唯一分页,成员 availablePackageCount 为该入仓码在本托的箱数,行 availablePackageCount 为整托总箱数。搜索命中任一成员仍返回整托完整组成;普通箱件候选为空数组。 description: 同柜组或封托托盘的完整入仓成员;SEALED_PALLET 行按托盘唯一分页,成员 availablePackageCount 为该入仓码在本托的箱数,行 availablePackageCount 为整托总箱数。搜索命中任一成员仍返回整托完整组成;普通箱件候选为空数组。
items: items: {$ref: '#/components/schemas/SmartLoadingCandidateGroupMember'}
type: object SmartLoadingCandidateGroupMember:
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount] type: object
properties: required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
accountId: {type: string, pattern: '^[1-9][0-9]*$'} properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'} accountId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'} inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string} orderNo: {type: string}
customerName: {type: string} inboundCode: {type: string}
availablePackageCount: {type: integer, minimum: 0} customerName: {type: string}
orderStatusSnapshot: {type: string, nullable: true} availablePackageCount: {type: integer, minimum: 0}
expectedArrivalAt: {type: string, format: date-time, nullable: true} orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
SmartLoadingWorkbenchContainerPageEnvelope: SmartLoadingWorkbenchContainerPageEnvelope:
type: object type: object
required: [code, message, data] required: [code, message, data]
@@ -2496,7 +2496,7 @@ components:
version: {type: integer, minimum: 0, description: 柜次版本} version: {type: integer, minimum: 0, description: 柜次版本}
SmartLoadingPlan: SmartLoadingPlan:
type: object 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: properties:
id: {type: string} id: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
@@ -2516,6 +2516,10 @@ components:
type: array type: array
description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建 description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建
items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'} items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'}
palletMembers:
type: array
description: 当前计划内每个整托按入仓码汇总的实际装柜箱数;用于一托一行时展示完整组成
items: {$ref: '#/components/schemas/SmartLoadingPlanPalletMembers'}
items: items:
type: array type: array
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'} items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
@@ -2534,6 +2538,15 @@ components:
minItems: 1 minItems: 1
uniqueItems: true uniqueItems: true
items: {type: string, pattern: '^[1-9][0-9]*$'} 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: SmartLoadingPlanItem:
type: object type: object
required: [id, packageCount, totalPackageCount, remainingPackageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove] required: [id, packageCount, totalPackageCount, remainingPackageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
@@ -109,7 +109,7 @@ components:
required: [expectedVersion, fleetSupplierId] required: [expectedVersion, fleetSupplierId]
properties: properties:
expectedVersion: {type: integer, minimum: 0} 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: 可选一致性提示,持久化名称以服务端快照为准} fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准}
reason: {type: string, maxLength: 500, nullable: true} reason: {type: string, maxLength: 500, nullable: true}
PickupAppointmentRequest: PickupAppointmentRequest:
@@ -223,7 +223,7 @@ components:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.} oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading 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.} 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.} 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.} 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} plannedLoadingAt: {type: string, format: date-time}
@@ -92,7 +92,7 @@ class OceanBookingServiceTest {
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception { private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
stubMasterData(); stubMasterData();
when(suppliers.booking(81L, "订舱代理")).thenReturn( 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()); var tree = objectMapper.valueToTree(completeDraft());
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName"); ((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class); DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
@@ -243,7 +243,7 @@ class OceanBookingServiceTest {
stubMasterData(); stubMasterData();
when(suppliers.booking(81L, null)).thenReturn( 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", assertThatThrownBy(() -> service.create(9L, "admin", "agent-mismatch",
new CreateRequest(0, completeDraft()))) new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司"); .isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司");
@@ -253,8 +253,8 @@ class OceanBookingServiceTest {
@Test @Test
void bookingAgentCandidatesAreFilteredByCarrierCode() { void bookingAgentCandidatesAreFilteredByCarrierCode() {
when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of( when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of(
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON")), new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON"), null),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML")))); new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"), null)));
assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name) assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name)
.containsExactly("匹配代理"); .containsExactly("匹配代理");
@@ -268,7 +268,7 @@ class OceanBookingServiceTest {
when(repository.parties(1L)).thenReturn(parties()); when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement())); when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(suppliers.booking(81L, null)).thenReturn( 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", assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-agent-mismatch",
new ReleaseRequest(3, "确认"))) new ReleaseRequest(3, "确认")))
@@ -392,7 +392,7 @@ class OceanBookingServiceTest {
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE")))); 701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn( 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( when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER", new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER",
"MATSON", List.of("CARRIER"), "ACTIVE"))); "MATSON", List.of("CARRIER"), "ACTIVE")));
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Set; import java.util.Set;
import java.util.Collection;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
@@ -34,6 +35,12 @@ class SmartLoadingCandidateMapperContractTest {
.contains("WHERE c.pallet_id=#{palletId}") .contains("WHERE c.pallet_id=#{palletId}")
.contains("GROUP BY o.tenant_id,o.id") .contains("GROUP BY o.tenant_id,o.id")
.doesNotContain("#{inboundId}"); .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 @Test
void candidateHasOnlyTheCanonicalMybatisConstructor() { void candidateHasOnlyTheCanonicalMybatisConstructor() {
@@ -220,7 +220,7 @@ class ContainerFeeDetailServiceTest {
when(feeItems.findItem(11L)).thenReturn(item()); when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn( when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot( 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); LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE", var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L, 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 -> when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING", new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null, 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 -> when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A")) when(suppliers.overseasTrucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of())); .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
} }
@Test @Test
@@ -159,6 +159,40 @@ class SmartLoadingServiceTest {
verify(coloadGroups,never()).findByInbound(anyLong(),anyLong()); 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 @Test
void restoresLoadingPlanForContinuedAssignment() { void restoresLoadingPlanForContinuedAssignment() {
LoadingPlanEntity plan = plan("LOADING", 3); LoadingPlanEntity plan = plan("LOADING", 3);
@@ -59,7 +59,7 @@ class TruckAppointmentFeeServiceTest {
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111"); assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate()); assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100"); 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, "系统(拖车离仓完成)"); verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
} }
@@ -43,8 +43,8 @@ class TruckAppointmentPayableFlowTest {
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING"); item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item); when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
when(items.findItem(108L)).thenReturn(item); when(items.findItem(108L)).thenReturn(item);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of()); var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC");
when(suppliers.trucking(8L, null)).thenReturn(supplier); when(suppliers.domesticTrucking(8L, null)).thenReturn(supplier);
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier); when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L); var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567"); var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
@@ -58,8 +58,8 @@ class TruckAppointmentServiceTest {
return null; return null;
}).when(repository).insert(any()); }).when(repository).insert(any());
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001"); lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(suppliers.trucking(any(), any())).thenReturn( when(suppliers.domesticTrucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of())); new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of()); lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking(); OceanBookingEntity booking = releasedBooking();
lenient().when(oceanBookings.find(99L)).thenReturn(booking); lenient().when(oceanBookings.find(99L)).thenReturn(booking);
@@ -536,7 +536,7 @@ class TruckAppointmentServiceTest {
@Test void createRejectsInvalidTruckingSupplierWithoutWriting() { @Test void createRejectsInvalidTruckingSupplierWithoutWriting() {
SaveRequest request = validRequest(); SaveRequest request = validRequest();
when(suppliers.trucking(8L, null)).thenThrow( when(suppliers.domesticTrucking(8L, null)).thenThrow(
new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用")); new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用"));
assertThatThrownBy(() -> service.create(7L, "admin", request)) assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -28,7 +28,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test @Test
void rejectsFreeTextWithoutSupplierId() { void rejectsFreeTextWithoutSupplierId() {
assertThatThrownBy(() -> service.trucking(null, "任意承运商")) assertThatThrownBy(() -> service.domesticTrucking(null, "任意承运商"))
.isInstanceOf(BizException.class).hasMessageContaining("自由文本"); .isInstanceOf(BizException.class).hasMessageContaining("自由文本");
verifyNoInteractions(client); verifyNoInteractions(client);
} }
@@ -36,7 +36,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test @Test
void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() { void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() {
when(client.get("internal-token", 7L, true, "BOOKING")) 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, "海运代理"); var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理"); assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING"); verify(client).get("internal-token", 7L, true, "BOOKING");
@@ -46,7 +46,7 @@ class WarehouseSupplierSnapshotServiceTest {
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() { void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null)) when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( .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("任意启用供应商"); assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null); verify(client).get("internal-token", 8L, true, null);
@@ -55,7 +55,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test @Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() { void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) 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, "伪造名称")) assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致"); .isInstanceOf(BizException.class).hasMessageContaining("不一致");
} }
@@ -64,9 +64,29 @@ class WarehouseSupplierSnapshotServiceTest {
void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() { void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() {
when(client.get("internal-token", 7L, true, "BOOKING")) when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( .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)) assertThatThrownBy(() -> service.booking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); .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))) new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessage("货柜实际到达目的港后才能安排码头车队"); .hasMessage("货柜实际到达目的港后才能安排码头车队");
verify(suppliers, never()).trucking(anyLong(), any()); verify(suppliers, never()).overseasTrucking(anyLong(), any());
} }
@Test @Test
@@ -82,8 +82,8 @@ class TerminalDrayageServiceTest {
when(mapper.context(11L)).thenReturn(context); when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null); when(mapper.lock(11L)).thenReturn(null);
when(mapper.operation(11L, "fleet-1")).thenReturn(null); when(mapper.operation(11L, "fleet-1")).thenReturn(null);
when(suppliers.trucking(91L, "用户提示名")).thenReturn( when(suppliers.overseasTrucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>(); AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
TerminalDrayageEntity value = invocation.getArgument(0); TerminalDrayageEntity value = invocation.getArgument(0);
@@ -256,8 +256,8 @@ class TerminalDrayageServiceTest {
when(mapper.lockContext(11L)).thenReturn(context); when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context); when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null); when(mapper.lock(11L)).thenReturn(null);
when(suppliers.trucking(91L, null)).thenReturn( when(suppliers.overseasTrucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>(); AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
AtomicReference<Operation> operation = new AtomicReference<>(); AtomicReference<Operation> operation = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
@@ -279,7 +279,7 @@ class TerminalDrayageServiceTest {
assertThat(replay).isEqualTo(first); assertThat(replay).isEqualTo(first);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); 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 @Test
@@ -317,7 +317,7 @@ class TerminalDrayageServiceTest {
void selectingSameFleetDoesNotQueueMailOrChangeVersion() { void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null)); when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
when(mapper.lock(11L)).thenReturn(row(3)); 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)); service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
verifyNoInteractions(mails); verifyNoInteractions(mails);
verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString()); verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
@@ -332,7 +332,7 @@ class TerminalDrayageServiceTest {
when(mapper.lock(11L)).thenReturn(row); when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row); when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1); 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)); service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
var ordered = org.mockito.Mockito.inOrder(mails,mapper); var ordered = org.mockito.Mockito.inOrder(mails,mapper);
ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L); 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`;验证装柜完成、报关和订舱相关状态组合 | | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-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` | | 海外主数据 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` | | 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-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-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 ```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes 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 { return {
name: `财务验收供应商-${suffix}`, name: `财务验收供应商-${suffix}`,
category, category,
truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null,
...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}), ...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}),
region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人', region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人',
phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`, 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) { async function createTruck(context, oceanBooking, index) {
const carrierResponse = await context.request('GET', 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 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 承运商`) if (!carrier) throw new Error(`创建拖车样本 ${index} 缺少 ACTIVE 承运商`)
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index)) const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, carrier, index))
const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) 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) }, addStep(_module, name, _response, passed) { assert.ok(passed, name) },
addCoverageGap() { assert.fail('unexpected missing fixture') }, addCoverageGap() { assert.fail('unexpected missing fixture') },
async request(method, path, body, _token, headers) { 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 (method === 'GET') {
if (path.endsWith('/history')) return response(history) if (path.endsWith('/history')) return response(history)
if (path.includes('?containerBookingIds=')) return response([state]) 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 if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商', const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100') '/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') const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE' && item?.category === 'TRUCKING' && item?.truckingScope === 'OVERSEAS')
if (!fleet) { if (!fleet) {
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response) blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING + OVERSEAS公共供应商;脚本不临时创建主数据', suppliers.response)
return null return null
} }
const selected = await command(context, target, '选择车队', 'fleet', { 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', id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED',
`oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS) `oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS)
const carrierResponse = await requestWithTransientRetry(context, 'GET', 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 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, context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse,
Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
if (!carrier) return if (!carrier) return
@@ -79,13 +79,14 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
} }
async function activeSupplier(context, category, suffix) { 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 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' const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE'
&& (category !== 'TRUCKING' || item?.truckingScope === 'DOMESTIC')
&& (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON'))) && (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON')))
if (existing) return { supplierId: id(existing.id), supplierName: existing.name } if (existing) return { supplierId: id(existing.id), supplierName: existing.name }
const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', { 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', region: '深圳', legalRepresentative: '验收法人', contactName: '验收联系人', phone: '13800000000',
email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64), email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64),
licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, 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/PlatformPalletController.java`、`service/PalletApplicationService.java` |
| 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` | | 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` |
| 海外仓阶梯费用 | `controller/PlatformOverseasStorageFeeController.java`、`service/OverseasStorageFeeService.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` | | 国内整柜业务 | `domesticcontainer/controller/PlatformDomesticContainerController.java`、`domesticcontainer/service/DomesticContainerService.java`;契约见 `src/main/resources/openapi/domestic-container-v1.yaml` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 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, @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) { @PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) {
requireToken(token); 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); var supplier = service.detail(supplierId);
return ApiResponse.success(new TruckingContact(supplierId, supplier.name(), supplier.email())); 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')") @GetMapping("/import-template") @PreAuthorize("hasAuthority('platform:supplier:create')")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) { public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); HttpHeaders headers = new HttpHeaders(); 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")); headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(transferService.template()); return ResponseEntity.ok().headers(headers).body(transferService.template());
} }
@@ -19,7 +19,8 @@ public final class SupplierDtos {
@Size(max = 80) String region, @Size(max = 80) String region,
@Pattern(regexp = "ACTIVE|INACTIVE") String status, @Pattern(regexp = "ACTIVE|INACTIVE") String status,
String keyword, 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 { public PageQuery {
page = page == null ? 1 : page; page = page == null ? 1 : page;
size = size == null ? 20 : size; size = size == null ? 20 : size;
@@ -40,7 +41,8 @@ public final class SupplierDtos {
@Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId, @Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status, @NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status,
@NotBlank @Pattern(regexp = "MONTHLY_30|MONTHLY_45|MONTHLY_60|MONTHLY_90|PER_TRANSACTION|IMMEDIATE") String settlementPeriod, @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) {} 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 businessLicenseNo, String licenseFileId, String status,
String settlementPeriod, String remark, Long createdBy, String settlementPeriod, String remark, Long createdBy,
LocalDateTime createdAt, Long updatedBy, LocalDateTime updatedAt, 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, 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 ImportError(int row, String field, String message) {}
public record ImportResult(String fileName, int totalRows, int importedRows, boolean success, public record ImportResult(String fileName, int totalRows, int importedRows, boolean success,
List<ImportError> errors) {} List<ImportError> errors) {}
@@ -19,6 +19,8 @@ public class SupplierEntity extends BaseEntity {
private Long tenantId; private Long tenantId;
private String name; private String name;
private String category; private String category;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String truckingScope;
private String region; private String region;
private String legalRepresentative; private String legalRepresentative;
private String contactName; private String contactName;
@@ -25,6 +25,7 @@ public class SupplierRepository {
} }
LambdaQueryWrapper<SupplierEntity> w = base(tenantId) LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category()) .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.region()), SupplierEntity::getRegion, query.region())
.eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.carrierCode()), SupplierEntity::getCategory, "BOOKING") .eq(StringUtils.hasText(query.carrierCode()), SupplierEntity::getCategory, "BOOKING")
@@ -32,11 +32,11 @@ import java.util.Set;
public class SupplierImportExportService { public class SupplierImportExportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024; private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000; 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> 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 Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
private static final String[] HEADERS = {"供应商名称*", "供应商类别*", "船司标准编码(多值)", "地区*", "法人代表*", private static final String[] HEADERS = {"供应商名称*", "供应商类别*", "船司标准编码(多值)", "地区*", "法人代表*",
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注"}; "联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注", "拖车类型(TRUCKING必填)"};
@Resource private SupplierService supplierService; @Resource private SupplierService supplierService;
@Resource private SupplierRepository repository; @Resource private SupplierRepository repository;
@@ -47,7 +47,7 @@ public class SupplierImportExportService {
XSSFSheet sheet = workbook.createSheet("供应商"); XSSFSheet sheet = workbook.createSheet("供应商");
Row version = sheet.createRow(0); version.createCell(0).setCellValue("模板版本"); version.createCell(1).setCellValue(TEMPLATE_VERSION); Row version = sheet.createRow(0); version.createCell(0).setCellValue("模板版本"); version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明"); 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); Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); sheet.setColumnWidth(i, 22 * 256); } 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(); workbook.write(output); return output.toByteArray();
@@ -87,10 +87,10 @@ public class SupplierImportExportService {
} }
public byte[] exportCsv(PageQuery query) { 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; int page = 1;
while (true) { 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)); for (SupplierResource value : result.records()) csv.append(row(value));
if (page >= result.pages()) break; if (page >= result.pages()) break;
page++; page++;
@@ -103,6 +103,11 @@ public class SupplierImportExportService {
errors.add(new ImportError(row.number(), violation.getPropertyPath().toString(), violation.getMessage())); errors.add(new ImportError(row.number(), violation.getPropertyPath().toString(), violation.getMessage()));
} }
if (!CATEGORIES.contains(row.request().category())) errors.add(new ImportError(row.number(), "category", "供应商类别无效")); 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()); 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至少关联一个船司编码"));
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)), 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)), 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), 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) { private static List<String> carrierCodes(String value) {
LinkedHashSet<String> result = new LinkedHashSet<>(); LinkedHashSet<String> result = new LinkedHashSet<>();
@@ -127,7 +132,7 @@ public class SupplierImportExportService {
private static String row(SupplierResource v) { private static String row(SupplierResource v) {
return List.of(Long.toString(v.id()), v.name(), v.category(), String.join("|", v.carrierCodes()), v.region(), 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()), 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"; .stream().map(SupplierImportExportService::csv).collect(java.util.stream.Collectors.joining(",")) + "\r\n";
} }
private static String csv(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; } private static String csv(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; }
@@ -40,6 +40,7 @@ public class SupplierService {
@Transactional @Transactional
@BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#id", description = "新增供应商", captureBefore = false) @BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#id", description = "新增供应商", captureBefore = false)
public Long create(long id, SaveRequest request) { public Long create(long id, SaveRequest request) {
validateTruckingScope(request.category(), request.truckingScope());
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes()); List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
ensureName(request.name(), null); ensureName(request.name(), null);
SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request); SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request);
@@ -53,6 +54,7 @@ public class SupplierService {
@Transactional @Transactional
@BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商") @BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商")
public void update(long id, SaveRequest request) { public void update(long id, SaveRequest request) {
validateTruckingScope(request.category(), request.truckingScope());
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes()); List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request); SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request);
try { try {
@@ -74,9 +76,9 @@ public class SupplierService {
public List<InternalSnapshot> internalBookingCandidates(String carrierCode) { public List<InternalSnapshot> internalBookingCandidates(String carrierCode) {
return page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, 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(), .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; } 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) { private List<String> carrierCodes(long id) {
return repository.carrierCodes(PUBLIC_TENANT_ID, List.of(id)).getOrDefault(id, List.of()); 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) { private static List<String> normalizeCarrierCodes(String category, List<String> values) {
LinkedHashSet<String> normalized = new LinkedHashSet<>(); LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (values != null) values.forEach(value -> { if (values != null) values.forEach(value -> {
@@ -110,14 +122,14 @@ public class SupplierService {
} }
private static InternalSnapshot snapshot(SupplierEntity e, List<String> carrierCodes) { private static InternalSnapshot snapshot(SupplierEntity e, List<String> carrierCodes) {
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus(), 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) { private static SupplierResource resource(SupplierEntity e, List<String> carrierCodes) {
return new SupplierResource(e.getId(), e.getTenantId(), e.getName(), e.getCategory(), return new SupplierResource(e.getId(), e.getTenantId(), e.getName(), e.getCategory(),
List.copyOf(carrierCodes), e.getRegion(), e.getLegalRepresentative(), e.getContactName(), List.copyOf(carrierCodes), e.getRegion(), e.getLegalRepresentative(), e.getContactName(),
e.getPhone(), e.getEmail(), e.getAddress(), e.getBusinessLicenseNo(), e.getLicenseFileId(), e.getPhone(), e.getEmail(), e.getAddress(), e.getBusinessLicenseNo(), e.getLicenseFileId(),
e.getStatus(), e.getSettlementPeriod(), e.getRemark(), e.getCreatedBy(), e.getCreatedAt(), 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; USE baiyue_warehouse;
SET NAMES utf8mb4; SET NAMES utf8mb4;
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称', name VARCHAR(160) NOT NULL COMMENT '供应商名称',
category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
trucking_scope VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空',
region VARCHAR(80) NOT NULL COMMENT '地区', region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表',
contact_name 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 '乐观锁版本', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_wh_supplier_name (tenant_id,name), 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_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0), CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0),
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')), 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)'); 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; 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; PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing; 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' 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(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_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='供应商表、索引或约束初始化不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
END IF; END IF;
END$$ END$$
@@ -48,6 +48,7 @@
| `page` | integer | 否 | 默认 `1`,最小 `1` | | `page` | integer | 否 | 默认 `1`,最小 `1` |
| `size` | integer | 否 | 默认 `20`,范围 `1..200` | | `size` | integer | 否 | 默认 `20`,范围 `1..200` |
| `category` | enum | 否 | 供应商分类 | | `category` | enum | 否 | 供应商分类 |
| `truckingScope` | enum | 否 | DOMESTIC 国内拖车或 OVERSEAS 海外车队;拖车业务候选必须按对应类型筛选 |
| `region` | string | 否 | 地区精确匹配,最长 80 | | `region` | string | 否 | 地区精确匹配,最长 80 |
| `status` | enum | 否 | 合作状态 | | `status` | enum | 否 | 合作状态 |
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 | | `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>`。服务端完成关联过滤,前端不得先拉全量再自行过滤。 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: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } } - { 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: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } } - { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } } - { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
@@ -32,7 +33,7 @@ paths:
/api/platform/v1/suppliers/import: /api/platform/v1/suppliers/import:
post: post:
summary: 原子批量导入供应商 summary: 原子批量导入供应商
description: 权限 platform:supplier:create;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。 description: 权限 platform:supplier:create;使用版本3模板,TRUCKING必须填写DOMESTIC或OVERSEAS拖车类型,其他类别留空;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }] parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
@@ -45,6 +46,7 @@ paths:
parameters: parameters:
- { $ref: '#/components/parameters/TenantId' } - { $ref: '#/components/parameters/TenantId' }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } } - { 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: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } } - { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, schema: { type: string } } - { name: keyword, in: query, schema: { type: string } }
@@ -84,7 +86,7 @@ paths:
/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact: /internal/warehouse/v1/suppliers/{supplierId}/trucking-contact:
get: get:
summary: 内部查询码头车队收件资料 summary: 内部查询码头车队收件资料
description: 固定校验TRUCKING类别与平台归属;取消旧车队请求允许requireActive=false。 description: 固定校验TRUCKING类别、OVERSEAS拖车类型与平台归属;取消旧车队请求允许requireActive=false。
security: [{internalServiceToken: []}] security: [{internalServiceToken: []}]
parameters: parameters:
- {name: supplierId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} - {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] } } RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } }
schemas: schemas:
Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] } 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] } 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 } CarrierCodes: { type: array, uniqueItems: true, maxItems: 7, items: { $ref: '#/components/schemas/CarrierCode' }, description: BOOKING必填且至少一个;非BOOKING可省略或为空,提交非空数组返回A0400 }
Status: { type: string, enum: [ACTIVE, INACTIVE] } Status: { type: string, enum: [ACTIVE, INACTIVE] }
@@ -143,6 +146,7 @@ components:
properties: properties:
name: { type: string, maxLength: 160 } name: { type: string, maxLength: 160 }
category: { $ref: '#/components/schemas/Category' } category: { $ref: '#/components/schemas/Category' }
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' } carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
region: { type: string, maxLength: 80 } region: { type: string, maxLength: 80 }
legalRepresentative: { type: string, maxLength: 80 } legalRepresentative: { type: string, maxLength: 80 }
@@ -202,6 +206,7 @@ components:
supplierId: { type: integer, format: int64 } supplierId: { type: integer, format: int64 }
name: { type: string } name: { type: string }
category: { $ref: '#/components/schemas/Category' } category: { $ref: '#/components/schemas/Category' }
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
status: { $ref: '#/components/schemas/Status' } status: { $ref: '#/components/schemas/Status' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' } carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
InternalSnapshotResponse: InternalSnapshotResponse:
@@ -20,8 +20,15 @@ class InternalSupplierContactTest {
var supplier = mock(SupplierResource.class); var supplier = mock(SupplierResource.class);
when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com"); when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com");
when(service.detail(1L)).thenReturn(supplier); 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(); var result = controller.truckingContact("test-token",1L,false).data();
assertThat(result.email()).isEqualTo("fleet@example.com"); assertThat(result.email()).isEqualTo("fleet@example.com");
verify(service).internalSnapshot(1L,false,"TRUCKING"); 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); 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", 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)", "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", "MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE",
"created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED", "created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED",
"supplier_add_column_if_missing", "uk_wh_supplier_name", "idx_wh_supplier_filter", "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 ("), String supplierTable = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier ("),
sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier_carrier")); sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier_carrier"));
assertThat(supplierTable).doesNotContain("carrier_code", "carrier_codes"); 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) @ExtendWith(MockitoExtension.class)
class SupplierImportExportServiceTest { class SupplierImportExportServiceTest {
@Mock private SupplierService supplierService; @Mock private SupplierService supplierService;
@Mock private com.baiyue.warehouse.repository.SupplierRepository repository;
@InjectMocks private SupplierImportExportService service; @InjectMocks private SupplierImportExportService service;
private PageQuery query; 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 { @Test void templateUsesMultiCarrierCodesColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(service.template()))) { 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("船司标准编码(多值)"); assertThat(workbook.getSheetAt(0).getRow(2).getCell(2).getStringCellValue()).isEqualTo("船司标准编码(多值)");
} }
} }
@@ -36,10 +38,49 @@ class SupplierImportExportServiceTest {
@Test void csvExportsCarrierCodesWithoutChangingPersistenceShape() { @Test void csvExportsCarrierCodesWithoutChangingPersistenceShape() {
SupplierResource resource = new SupplierResource(7L, 0L, "订舱代理", "BOOKING", List.of("MATSON", "ZIM"), SupplierResource resource = new SupplierResource(7L, 0L, "订舱代理", "BOOKING", List.of("MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, "宁波", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null,
null, null, null, null, false, 0); null, null, null, null, false, 0, null);
when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON"))) when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON", null)))
.thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1)); .thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1));
String csv = new String(service.exportCsv(query), StandardCharsets.UTF_8); String csv = new String(service.exportCsv(query), StandardCharsets.UTF_8);
assertThat(csv).contains("船司标准编码", "\"MATSON|ZIM\""); 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() { @BeforeEach void setUp() {
booking = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON"), "宁波", "法人", "联系人", booking = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON"), "宁波", "法人", "联系人",
"13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001", "13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001",
"ACTIVE", "MONTHLY_30", null); "ACTIVE", "MONTHLY_30", null, null);
} }
@Test void createsPlatformPublicBookingSupplier() { @Test void createsPlatformPublicBookingSupplier() {
@@ -42,26 +42,26 @@ class SupplierServiceTest {
@Test void deduplicatesBookingCarrierCodesAndRejectsNonBookingAssociations() { @Test void deduplicatesBookingCarrierCodesAndRejectsNonBookingAssociations() {
SaveRequest duplicated = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON", "MATSON", "ZIM"), SaveRequest duplicated = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON", "MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null, "宁波", "法人", "联系人", "13800000000", null, null, null, null,
"ACTIVE", "MONTHLY_30", null); "ACTIVE", "MONTHLY_30", null, null);
service.create(102L, duplicated); service.create(102L, duplicated);
verify(repository).replaceCarrierCodes(eq(0L), eq(102L), eq(java.util.List.of("MATSON", "ZIM")), any()); 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"), "宁波", "法人", 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) assertThatThrownBy(() -> service.create(103L, customs)).isInstanceOf(BizException.class)
.hasMessageContaining("非BOOKING"); .hasMessageContaining("非BOOKING");
} }
@Test void bookingRequiresAtLeastOneCarrierCode() { @Test void bookingRequiresAtLeastOneCarrierCode() {
SaveRequest empty = new SaveRequest("测试供应商", "BOOKING", java.util.List.of(), "宁波", "法人", "联系人", 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) assertThatThrownBy(() -> service.create(104L, empty)).isInstanceOf(BizException.class)
.hasMessageContaining("至少关联一个"); .hasMessageContaining("至少关联一个");
} }
@Test void nonBookingMayOmitCarrierCodes() { @Test void nonBookingMayOmitCarrierCodes() {
SaveRequest customs = new SaveRequest("报关行", "CUSTOMS", null, "宁波", "法人", "联系人", 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); service.create(105L, customs);
verify(repository).replaceCarrierCodes(eq(0L), eq(105L), eq(java.util.List.of()), any()); 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")) assertThatThrownBy(() -> service.internalSnapshot(101L, true, "CUSTOMS"))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); .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);
}
} }