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