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