diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 942e6c642..ddfc62a86 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -32,6 +32,8 @@ 历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。 +预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。 + ## 唯一入口与区域边界 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 diff --git a/deploy/sql/reset-test-fba-appointments.sql b/deploy/sql/reset-test-fba-appointments.sql index b49fb888a..fab3f1af6 100644 --- a/deploy/sql/reset-test-fba-appointments.sql +++ b/deploy/sql/reset-test-fba-appointments.sql @@ -1,9 +1,26 @@ -- TEST ONLY. Rebuild the explicitly authorized appointment header and item tables. --- The new command-history table MUST NOT already exist; check this before executing any DROP. --- If it exists, stop and inspect the prior outcome. Do not clear command history or blindly retry. +-- The command-history and cargo-verification tables MUST NOT already exist; check both before any DROP. +-- If either exists, stop and inspect the prior outcome. Do not clear history or blindly retry. -- Requires explicit approval; never run against production or as automatic startup SQL. -- Caller must verify the TEST connection before executing. USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_test_appointment_reset_preconditions; +DELIMITER $$ +CREATE PROCEDURE assert_test_appointment_reset_preconditions() +BEGIN + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification', + 'll_appointment_work','ll_dispatch_appointment_allocation')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('ll_inbound_fba_appointment','ll_inbound_fba_appointment_item')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: expected both legacy appointment tables; inspect partial or new schema'; + END IF; +END$$ +DELIMITER ; +CALL assert_test_appointment_reset_preconditions(); +DROP PROCEDURE assert_test_appointment_reset_preconditions; DROP TABLE ll_inbound_fba_appointment_item; DROP TABLE ll_inbound_fba_appointment; CREATE TABLE ll_inbound_fba_appointment ( @@ -24,6 +41,9 @@ CREATE TABLE ll_inbound_fba_appointment ( appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码', selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数', + replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联', + repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留', created_by VARCHAR(128) NOT NULL COMMENT '登录操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间', @@ -43,19 +63,42 @@ CREATE TABLE ll_inbound_fba_appointment_item ( order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空', inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码', - package_count INT NULL COMMENT '所选整票预报箱数,未知保持空', + package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒', + cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用', - active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键', - active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key), - UNIQUE KEY uk_ll_fba_active_source (active_source_key), - UNIQUE KEY uk_ll_fba_active_order (active_order_id), + KEY idx_ll_fba_active_source (customer_id,source_key,active), + KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_customer_order (customer_id,order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +CREATE TABLE ll_appointment_cargo_verification ( + id BIGINT NOT NULL COMMENT '核实记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地', + source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本', + source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空', + version INT NOT NULL COMMENT '同一来源的核实版本,从一递增', + package_count INT NULL COMMENT '独立核实箱数,不回写原订单', + shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + remark VARCHAR(1000) NULL COMMENT '核实说明', + operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人', + operator_name VARCHAR(200) NULL COMMENT '操作人名称快照', + command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键', + request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要', + created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version), + UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), + CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), + CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + CREATE TABLE ll_fba_appointment_command ( command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要', diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 100c68e60..e70fee62f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4102,6 +4102,27 @@ CREATE TABLE fl_split_operation ( CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引'; +-- Current schema for actual destination unloading; never an overseas warehouse receipt. +CREATE TABLE fl_direct_delivery_unloading ( + container_booking_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source', + actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time', + timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone', + received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient', + evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference', + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (container_booking_id,inbound_id), + UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), + CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; + + CREATE TABLE fl_terminal_drayage ( id bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID', @@ -8335,4 +8356,11 @@ INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VAL (4000001,5118120,0,CURRENT_TIMESTAMP(3)), (4000001,5118130,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; +-- Appointment work independent write permissions (current baseline). +USE baiyue_iam; +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6b745c529..1dd8baf47 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -862,6 +862,9 @@ CREATE TABLE ll_inbound_fba_appointment ( appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码', selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数', + replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联', + repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留', created_by VARCHAR(128) NOT NULL COMMENT '登录操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间', @@ -881,19 +884,42 @@ CREATE TABLE ll_inbound_fba_appointment_item ( order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空', inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码', - package_count INT NULL COMMENT '所选整票预报箱数,未知保持空', + package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒', + cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用', - active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键', - active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key), - UNIQUE KEY uk_ll_fba_active_source (active_source_key), - UNIQUE KEY uk_ll_fba_active_order (active_order_id), + KEY idx_ll_fba_active_source (customer_id,source_key,active), + KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_customer_order (customer_id,order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +CREATE TABLE ll_appointment_cargo_verification ( + id BIGINT NOT NULL COMMENT '核实记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地', + source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本', + source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空', + version INT NOT NULL COMMENT '同一来源的核实版本,从一递增', + package_count INT NULL COMMENT '独立核实箱数,不回写原订单', + shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + remark VARCHAR(1000) NULL COMMENT '核实说明', + operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人', + operator_name VARCHAR(200) NULL COMMENT '操作人名称快照', + command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键', + request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要', + created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version), + UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), + CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), + CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + CREATE TABLE ll_fba_appointment_command ( command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要', @@ -4355,7 +4381,6 @@ CREATE TABLE ll_selfop_product ( KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables; USE baiyue_lastleg; @@ -4441,3 +4466,67 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily ( KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; + + +-- Appointment work scope, audit and physical batch allocation. +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation ( + dispatch_id BIGINT NOT NULL, + dispatch_item_id BIGINT NOT NULL, + plan_pallet_id BIGINT NOT NULL, + batch_key VARCHAR(96) NOT NULL, + customer_id VARCHAR(64) NOT NULL, + source_key VARCHAR(160) NOT NULL, + destination_key VARCHAR(160) NOT NULL, + package_count INT NOT NULL, + PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key), + KEY idx_dispatch_appointment_batch(batch_key,customer_id), + KEY idx_dispatch_appointment_dispatch(dispatch_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment ( + entity_key VARCHAR(160) NOT NULL, + state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + impact_version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (entity_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_guard ( + id INT NOT NULL COMMENT '工作事项协调锁,固定1', + scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标', + initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描', + scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本', + scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间', + requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本', + synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本', + scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1); + +CREATE TABLE IF NOT EXISTS ll_appointment_work ( + work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本', + retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作', + state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (work_item_key), + KEY idx_appointment_work_container (container_booking_id,retired) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; + +CREATE TABLE IF NOT EXISTS ll_appointment_work_history ( + id VARCHAR(36) NOT NULL COMMENT '历史记录标识', + work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识', + operator_name VARCHAR(128) NULL COMMENT '操作人显示名称', + action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作', + summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据', + command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空', + request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要', + response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变', + occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_work_command (work_item_key,command_key), + KEY idx_appointment_work_history (work_item_key,occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 208 AS initialized_tables; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 2d60e5d5e..02754314e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -54,7 +54,9 @@ | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | -| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/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/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` | +| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | +| 目的港码头拖车与还空预警 | `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 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | | 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` 发布应付 | diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index df1b0fc9e..c0bd8fc5a 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -248,3 +248,10 @@ PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows - 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 - 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 - 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 + + +## 预约工作集成时的精确边界核对 + +`PlatformContainerShippingNodeController` 归船节点切片,`sailingcalendar` 归订舱船期切片;不再误归共享技术层。既有海外事件适配、实装费用重算和渠道目录读取在 `reviewedClassDependencies` 按具体调用者、目标类型和理由登记,不放开整个切片。 + +现存全局 service 包同时承载暂扣、装柜费用和订舱文档编排,形成 loading.bridge.service、loading.service、oceanbooking.service、service 四包 SCC。它是尚未拆分的既有包级债务,本轮未宣称消除;`reviewedPackageCycles` 只冻结这一精确集合,新增节点或第二个环仍失败,价格簿查询与客户统计仍必须处于所有环之外。合同有新增消费者与新增环的负向测试。预约新增提供端通过原域适配接口消费事实,不增加该包环。 diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index cdcc88bd8..263522193 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; const moduleRoot = path.resolve(import.meta.dirname, '..'); const sourceRoots = { @@ -15,8 +16,8 @@ const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], - ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], - ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException|ContainerShippingNode)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage|sailingcalendar)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], @@ -261,14 +262,28 @@ function renderDot(report) { return lines.join('\n'); } -function checkContract(report, contractPath) { - const absolute = path.resolve(process.cwd(), contractPath); - const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); +export function contractViolations(report, contract) { const allowed = new Set(contract.allowedSliceEdges ?? []); - const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); - const additions = actual.filter(edge => !allowed.has(edge)); - const violations = additions.map(edge => `new slice dependency: ${edge}`); + const violations = []; + const cycles=new Set((contract.reviewedPackageCycles ?? []).filter(c=>c.reason?.trim() + && c.packages?.length>1 && c.packages.every(p=>!p.includes('*'))).map(c=>[...c.packages].sort().join('|'))); + for (const cycle of report.packageSccs ?? []) { + if (!cycles.has([...cycle].sort().join('|'))) violations.push(`unreviewed package cycle: ${cycle.join(' -> ')}`); + } + const reviewed = new Set(); + for (const edge of contract.reviewedClassDependencies ?? []) { + if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) { + violations.push('reviewed dependency requires exact classes and a rationale'); + continue; + } + reviewed.add(`${edge.source}->${edge.target}`); + } const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const edge of report.classDependencies) { + const from=classesByName.get(edge.source)?.slice, to=classesByName.get(edge.target)?.slice; + if (from && to && from!==to && !allowed.has(`${from}->${to}`) && !reviewed.has(`${edge.source}->${edge.target}`)) + violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`); + } for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { const actualClass = classesByName.get(fqcn); if (!actualClass) violations.push(`required class missing: ${fqcn}`); @@ -312,12 +327,19 @@ function checkContract(report, contractPath) { violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); } } + return violations; +} + +function checkContract(report, contractPath) { + const contract=JSON.parse(fs.readFileSync(path.resolve(process.cwd(),contractPath),'utf8')); + const violations=contractViolations(report,contract); if (violations.length > 0) { process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); process.exitCode = 1; } } +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const options = parseArgs(process.argv.slice(2)); const report = buildReport(options.sourceSet); if (options.check) checkContract(report, options.check); @@ -328,3 +350,5 @@ const rendered = options.format === 'json' : renderDot(report); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); + +} diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 101c2f807..d0bc18efd 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { contractViolations } from './analyze-firstleg-dependencies.mjs'; import path from 'node:path'; import test from 'node:test'; @@ -7,6 +9,17 @@ const moduleRoot = path.resolve(import.meta.dirname, '..'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json'); +test('reviewed original-domain calls do not open an entire slice or allow wildcard grants', () => { + const report=analyze(); const baseline=JSON.parse(readFileSync(contract,'utf8')); + assert.deepEqual(contractViolations(report,baseline),[]); + report.classDependencies.push({source:'com.baiyue.firstleg.service.SmartLoadingService',target:'com.baiyue.firstleg.domain.PriceBookChannelCatalog'}); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('SmartLoadingService->com.baiyue.firstleg.domain.PriceBookChannelCatalog'))); + baseline.reviewedClassDependencies.push({source:'com.baiyue.firstleg.*',target:'com.baiyue.firstleg.service.FbaPricingService',reason:'invalid wildcard'}); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('exact classes'))); + report.packageSccs.push(['com.baiyue.firstleg.service','com.baiyue.firstleg.pricebookquery.application']); + assert.ok(contractViolations(report,baseline).some(message=>message.includes('unreviewed package cycle'))); +}); + function analyze() { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], { cwd: moduleRoot, @@ -53,7 +66,7 @@ test('customer statistics application layer only depends inward', () => { assert.deepEqual(outwardDependencies, []); }); -test('price book query is a leaf slice and removes the package cycle', () => { +test('price book query remains a leaf slice outside reviewed pre-existing package cycles', () => { const report = analyze(); const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ @@ -67,7 +80,9 @@ test('price book query is a leaf slice and removes the package cycle', () => { assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); - assert.deepEqual(report.packageSccs, []); + assert.equal(report.packageSccs.some(cycle=>cycle.some(pkg=>pkg.startsWith('com.baiyue.firstleg.pricebookquery'))),false); + const reviewed=JSON.parse(readFileSync(contract,'utf8')).reviewedPackageCycles.map(c=>c.packages); + assert.deepEqual(report.packageSccs,reviewed); }); test('price book query application and consumers respect ports', () => { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java new file mode 100644 index 000000000..03879f376 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="lastleg-service",contextId="appointmentWorkRouteClient",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface AppointmentWorkRouteClient { + record Route(String flow) { } + @GetMapping("/internal/lastleg/appointment-work/containers/{id}/route") + ApiResponse route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentWorkSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentWorkSourceController.java new file mode 100644 index 000000000..1c4b006d8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalAppointmentWorkSourceController.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.portschedule.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequestMapping("/internal/firstleg/appointment-work") +public class InternalAppointmentWorkSourceController { + private final AppointmentWorkSourceService service; + private final String token; + public InternalAppointmentWorkSourceController(AppointmentWorkSourceService service, + @Value("${baiyue.security.internal-service-token:}") String token) { this.service=service; this.token=token; } + @GetMapping("/containers") + public ApiResponse discover(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied, + @RequestParam(required=false) String after,@RequestParam(defaultValue="200") int size) { + authorize(supplied); return ApiResponse.success(service.discover(after,size)); + } + @GetMapping("/containers/{id}") + public ApiResponse container(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied, + @PathVariable long id) { authorize(supplied); return ApiResponse.success(service.container(id)); } + @PostMapping("/facts") + public ApiResponse> facts(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied, + @RequestBody List requests) { authorize(supplied); return ApiResponse.success(service.facts(requests)); } + private void authorize(String supplied) { + if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8))) + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/AppointmentWorkSourceDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/AppointmentWorkSourceDtos.java new file mode 100644 index 000000000..2631b4263 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/AppointmentWorkSourceDtos.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.portschedule.dto; + +import java.time.*; +import java.util.List; + +/** Internal wire values. Unzoned source timestamps are never converted using the server zone. */ +public final class AppointmentWorkSourceDtos { + private AppointmentWorkSourceDtos() { } + public record Reference(String kind, String key, String businessNo, String label) { } + public record SourceReference(String customerId,String sourceKey,String destinationKey) { } + public record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind, + String originName, String destinationName, String timezone, List references, List replacesRequirementKeys, + List sources) { } + public record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, + LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, String sourceVersion, String establishedFlow, String timezone, List requirements) { } + public record SourcePage(List containers, String nextCursor, boolean complete, String scopeVersion) { } + public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { } + public record FactRequest(String workItemKey, String type, String flow, String containerBookingId, + String requirementKey, String batchKey, String tripKey, List batchKeys, List containers,List sources) { } + public record Slot(LocalDate date, LocalTime startTime, LocalDate endDate, LocalTime endTime, String timezone) { } + public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { } + public record Target(String kind, String containerBookingId) { } + public record Action(String code, String label, String targetWorkItemKey, Target target) { } + public record Responsibility(String kind, String id, String name) { } + public record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, String planStatus, + String executionStatus, Slot target, Slot confirmed, Actual actual, String timezone, String businessNo, String tripKey, + List readPermissions, List actionPermissions, Action action, List blockReasons, + List missingFields, List references, Responsibility responsibility) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapper.java new file mode 100644 index 000000000..1efc2a3af --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapper.java @@ -0,0 +1,43 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentWorkSourceMapper { + record Source(long id, String cabinetNo, String containerNo, String departurePortName, + String destinationWarehouseName, LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, int version, long factVersion, long deliveryVersion) { } + record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode, + String destinationKind, String destinationCode, String destinationName, int packageCount) { } + + String SOURCES = """ + SELECT b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name, + MIN(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) departed_local_at, + MIN(f.actual_at) sailed_evidence_local_at, + b.version,MAX(f.id) fact_version, + (SELECT COUNT(*) FROM fl_direct_delivery_unloading d WHERE d.container_booking_id=b.id) delivery_version + FROM fl_container_booking b JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id + WHERE b.record_status='ACTIVE' AND f.actual_at IS NOT NULL + AND f.standard_node IN ('DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED_DESTINATION_PORT', + 'DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED') + GROUP BY b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,b.version + ORDER BY b.id + """; + // A complete identity/version manifest also detects deletions or edits between cursor pages. + @Select(SOURCES) + List sources(); + + @Select(""" + SELECT i.inbound_id, i.account_id customer_id, i.order_id, i.order_no,i.inbound_code, + o.destination_type destination_kind,i.destination_warehouse_code destination_code, + i.destination_warehouse_name destination_name,SUM(i.package_count) package_count + FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0 + JOIN fl_inbound_order o ON o.id=i.inbound_id + WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED' + GROUP BY i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type, + i.destination_warehouse_code,i.destination_warehouse_name + ORDER BY i.inbound_id + """) + List cargo(@Param("id") long id); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 630b2bdfe..f256e9c9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -15,6 +15,15 @@ import java.util.List; @Mapper public interface CarrierScheduleQueryMapper extends BaseMapper { + @Select(""" + SELECT container_booking_id FROM fl_carrier_schedule_node_fact + WHERE standard_node='PICKED_UP' AND actual_at IS NOT NULL + GROUP BY container_booking_id + HAVING MAX(actual_at)>=#{from} AND MAX(actual_at)<#{to} + ORDER BY MAX(actual_at),container_booking_id LIMIT 1001 + """) + List selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to); + @Select(""" + """) + List calendarByContainers(@Param("ids") List ids); + @Select(""" SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java new file mode 100644 index 000000000..1ffb11ba4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFacts.java @@ -0,0 +1,100 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.security.MessageDigest; +import java.util.*; + +/** The terminal domain adapts its facts to a wire port; the work service never imports its persistence. */ +@Component +public class AppointmentWorkTerminalFacts implements OriginalFacts { + private static final String READ="platform:firstleg:terminal-drayage:list"; + private final TerminalDrayageMapper terminal; + private final DirectDeliveryMapper direct; + private final CarrierScheduleQueryService nodes; + private final UnloadingEvidenceGuard unloading; + private final ObjectMapper json; + public AppointmentWorkTerminalFacts(TerminalDrayageMapper terminal,DirectDeliveryMapper direct, + CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) { + this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json; + } + public String establishedFlow(long id) { + var container=direct.container(id); + boolean transfer=container!=null && container.warehouseUnloadedAt()!=null; + boolean delivery=direct.evidenceCount(id)>0; + if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实"); + return transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN"; + } + public Fact read(FactRequest request,ContainerSource source) { + long id=Long.parseLong(request.containerBookingId()); + var node=nodes.containerActualFacts(List.of(id)).stream().findFirst().orElse(null); + var plan=terminal.find(id); + var references=new ArrayList(); + source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);})); + var blocks=new ArrayList(); var missing=new ArrayList(); + LocalDateTime at=null; String zone=null; Slot confirmed=null; boolean exists=false; + String code=null,label=null,permission=null; Responsibility responsibility=null; + String businessNo=references.stream().filter(r->"CONTAINER".equals(r.kind())).map(Reference::businessNo).filter(Objects::nonNull).findFirst().orElse(null); + String impact="BASELINE"; + switch(request.type()) { + case "PICKUP" -> { + at=node==null?null:node.pickedUpAt(); + if(plan!=null && plan.getPickupAppointmentAt()!=null) { + var time=plan.getPickupAppointmentAt(); confirmed=new Slot(time.toLocalDate(),time.toLocalTime(),null,null,null); exists=true; + } + if(plan!=null && plan.getFleetSupplierId()!=null) + responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName()); + // The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants. + var history=terminal.histories(id); + long reschedules=Math.max(0,history.stream().filter(h->"SCHEDULE_PICKUP".equals(h.action())).count()-1); + long reassignments=Math.max(0,history.stream().filter(h->"SELECT_FLEET".equals(h.action())).count()-1); + impact=reschedules+":"+reassignments; + if(at==null) { + if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜"); + else if(plan==null || plan.getFleetSupplierId()==null) {code="SAVE_PLAN";label="选择码头车队";permission="platform:firstleg:terminal-drayage:fleet:edit";} + else if(confirmed==null) {code="REQUEST_BOOKING";label="预约提柜";permission="platform:firstleg:terminal-drayage:pickup-appointment:edit";} + else {code="RECORD_EXECUTION";label="记录实际提柜";permission="platform:firstleg:port-schedule-query:manual";} + } + } + case "EMPTY_RETURN" -> { + at=node==null?null:node.emptyReturnedAt(); + if(at==null && plan!=null) at=plan.getActualEmptyReturnAt(); + if(at==null && unloading.completedUnloading(id)==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空"); + else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";} + // Return responsibility belongs to the work handoff, not the historical pickup carrier. + } + case "DELIVERY" -> { + var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致")); + references.clear(); references.addAll(requirement.references()); + var row=direct.deliveries(id).stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致")); + at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode(); + if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货"); + else if(at==null) {code="RECORD_EXECUTION";label="登记目的仓实际卸货";permission="platform:firstleg:terminal-drayage:direct-unload:confirm";} + } + default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"不支持的Firstleg事实类型"); + } + if((at!=null || confirmed!=null) && zone==null) missing.add("SOURCE_TIMEZONE"); + Actual actual=at==null?null:new Actual(zone==null?null:at.atZone(ZoneId.of(zone)).toInstant(),at,zone); + Action action=code==null?null:new Action(code,label,null,new Target("FIRSTLEG_CONTAINER",source.containerBookingId())); + var fact=new Fact(request.workItemKey(),"pending",impact,exists,confirmed==null?"UNPLANNED":"CONFIRMED", + actual!=null?"COMPLETED":"PENDING",null,confirmed,actual,zone,businessNo,null,List.of(READ), + permission==null?List.of():List.of(permission),action,List.copyOf(blocks),List.copyOf(missing),List.copyOf(references),responsibility); + return new Fact(fact.workItemKey(),hash(fact),fact.impactToken(),fact.planExists(),fact.planStatus(),fact.executionStatus(), + fact.target(),fact.confirmed(),fact.actual(),fact.timezone(),fact.businessNo(),fact.tripKey(),fact.readPermissions(), + fact.actionPermissions(),fact.action(),fact.blockReasons(),fact.missingFields(),fact.references(),fact.responsibility()); + } + private String hash(Object value) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));} + catch(Exception error){throw new IllegalStateException(error);} + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java new file mode 100644 index 000000000..85cc6b379 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuard.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.client.AppointmentWorkRouteClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class DirectDeliveryRouteGuard { + private final AppointmentWorkRouteClient client; + private final String token; + public DirectDeliveryRouteGuard(AppointmentWorkRouteClient client,@Value("${baiyue.security.internal-service-token:}") String token){this.client=client;this.token=token;} + public void requireDirect(long id) { + if(token==null || token.isBlank()) throw unavailable(); + com.baiyue.common.core.api.ApiResponse response; + try {response=client.route(token,id);} catch(RuntimeException error){throw unavailable();} + if(response==null || !response.successful() || response.data()==null) throw unavailable(); + if(!"DIRECT".equals(response.data().flow())) throw new ResponseStatusException(HttpStatus.CONFLICT,"请先在预约工作中确认整柜直送路线"); + } + private ResponseStatusException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"无法核实已确认路线,请稍后重试");} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java new file mode 100644 index 000000000..da5e2cb91 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryService.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; + +/** Actual direct delivery evidence. It does not call warehouse receipt or create stock. */ +@Service +public class DirectDeliveryService { + public record Request(@Min(0) int expectedVersion,@NotNull LocalDateTime actualAt,@NotBlank String timezone, + @NotBlank @Size(max=128) String receivedBy,@NotBlank @Size(max=128) String evidenceNo,@Size(max=1000) String remark) { } + public record View(String requirementKey,String inboundCode,String orderNo,String destinationName,int packageCount, + int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark) { } + private final DirectDeliveryMapper mapper; + private final CarrierScheduleQueryService nodes; + private final ObjectMapper json; + private final DirectDeliveryRouteGuard routes; + public DirectDeliveryService(DirectDeliveryMapper mapper,CarrierScheduleQueryService nodes,ObjectMapper json,DirectDeliveryRouteGuard routes) { + this.mapper=mapper;this.nodes=nodes;this.json=json;this.routes=routes; + } + public List list(long id) { + var container=mapper.container(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + return mapper.deliveries(id).stream().map(this::view).toList(); + } + @Transactional + public View record(long id,long inboundId,long operatorId,String operatorName,String key,Request request) { + if(key==null || key.isBlank() || key.length()>128 || request==null) throw bad("请提供有效幂等键及卸货记录"); + var container=mapper.lockContainer(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + var current=mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst() + .orElseThrow(()->bad("该入仓来源不在本柜实际装柜清单内")); + String hash=hash(request); + if(key.equals(current.idempotencyKey())) { + if(!hash.equals(current.requestHash())) throw conflict("同一幂等键对应的卸货内容已变化"); + return view(current); + } + if(mapper.keyCount(id,key)>0) throw conflict("幂等键已被其他卸货记录使用"); + if(current.version()!=request.expectedVersion() || current.actualAt()!=null) throw conflict("实际卸货已确认,请刷新核对"); + if(container.warehouseUnloadedAt()!=null) throw conflict("已有海外仓卸柜事实,不能改记为直送"); + routes.requireDirect(id); + ZoneId zone; + try {if(!ZoneId.getAvailableZoneIds().contains(request.timezone())) throw new IllegalArgumentException(); zone=ZoneId.of(request.timezone());} catch(RuntimeException e){throw bad("请选择有效目的地时区");} + if(request.actualAt()==null || zone.getRules().getValidOffsets(request.actualAt()).size()!=1) + throw bad("实际卸货时间缺失或处于夏令时歧义区间,请核对"); + if(request.actualAt().atZone(zone).toInstant().isAfter(Instant.now())) throw bad("实际卸货时间不能在未来"); + if(request.receivedBy()==null || request.receivedBy().isBlank() || request.evidenceNo()==null || request.evidenceNo().isBlank()) + throw bad("请填写真实签收人及签收凭据号"); + var facts=nodes.containerActualFacts(List.of(id)); + var fact=facts.stream().filter(f->f.containerBookingId()==id).findFirst().orElse(null); + if(fact==null || fact.pickedUpAt()==null) throw conflict("实际提柜后才能记录直送卸货"); + if(fact.emptyReturnedAt()!=null) throw conflict("货柜已实际还空,不能新增卸货事实"); + // Pickup has no source zone. Its local clock cannot order a destination-zone actual; + // require the real pickup fact, without inventing an offset for it. + mapper.insert(id,inboundId,request.actualAt(),zone.getId(),request.receivedBy().trim(),request.evidenceNo().trim(), + request.remark(),key,hash,operatorId,operatorName); + return mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst().map(this::view).orElseThrow(); + } + private View view(DirectDeliveryMapper.Delivery row) { + return new View("INBOUND:"+row.inboundId(),row.inboundCode(),row.orderNo(),row.destinationName(),row.packageCount(), + row.version(),row.actualAt(),row.timezone(),row.receivedBy(),row.evidenceNo(),row.remark()); + } + private String hash(Object value) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));} + catch(Exception e){throw new IllegalStateException(e);} + } + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java index a7c9024bf..346e51f23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java @@ -23,11 +23,18 @@ public class TerminalDrayageCalendarService { @Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;} public record Calendar(List records,boolean truncated,int unclassifiedCount, String scopeBasis,String timeBasis) { } + @org.springframework.transaction.annotation.Transactional(readOnly=true) public Calendar calendar(LocalDate from,LocalDate to) { if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天"); - var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001); - if(rows.isEmpty()) return result(List.of(),false,0); + var planned=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001); + var actualIds=shipping.pickupCalendarContainers(from.atStartOfDay(),to.atStartOfDay()); + var candidates=new LinkedHashMap(); + planned.stream().limit(1000).forEach(row->candidates.put(row.containerBookingId(),row)); + if(!actualIds.isEmpty()) mapper.calendarByContainers(actualIds.stream().limit(1000).toList()) + .forEach(row->candidates.put(row.containerBookingId(),row)); + boolean truncated=planned.size()>1000 || actualIds.size()>1000; + if(candidates.isEmpty()) return result(List.of(),truncated,0); final Map countries=new HashMap<>(); try { var response=warehouses.catalog(token); @@ -36,7 +43,7 @@ public class TerminalDrayageCalendarService { countries.put(warehouse.warehouseCode(),warehouse.countryCode()); } catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);} var records=new ArrayList();int unknown=0; - for(var row:rows.stream().limit(1000).toList()) { + for(var row:candidates.values()) { var country=countries.get(row.destinationWarehouseCode()); if(country==null || country.isBlank()) unknown++; else if("US".equals(country)) records.add(row); @@ -48,8 +55,13 @@ public class TerminalDrayageCalendarService { if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt()); } var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(), - row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList(); - return result(enriched,rows.size()>1000,unknown); + row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))) + .filter(row->{var at=occurrence(row);return at!=null && !at.isBefore(from.atStartOfDay()) && at.isBefore(to.atStartOfDay());}) + .sorted(Comparator.comparing(TerminalDrayageCalendarService::occurrence).thenComparing(PickupCalendarEntry::containerBookingId)).toList(); + return result(enriched.stream().limit(1000).toList(),truncated || enriched.size()>1000,unknown); + } + private static java.time.LocalDateTime occurrence(PickupCalendarEntry row) { + return row.actualPickupAt()!=null?row.actualPickupAt():row.pickupAppointmentAt(); } private Calendar result(List records,boolean truncated,int unknown) { return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 2035ae70f..639405693 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -48,6 +48,7 @@ public class TerminalDrayageService { private final CarrierScheduleQueryService carrierSchedules; private final ObjectMapper objectMapper; private final TerminalDrayageMailService mails; + private final UnloadingEvidenceGuard unloadingEvidence; private Clock clock = Clock.systemDefaultZone(); public View detail(long containerBookingId) { @@ -145,10 +146,10 @@ public class TerminalDrayageService { TerminalDrayageEntity value = mapper.lock(containerBookingId); requireVersion(value, request.expectedVersion()); LocalDateTime pickupAt = actualPickupAt(context, value); - if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); - LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt()) - ? pickupAt : context.base().unloadingCompletedAt(); - if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); + if (completedUnloading(context) == null) throw conflict("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); + LocalDateTime earliest = context.base().unloadingCompletedAt(); + if (pickupAt != null && (earliest == null || pickupAt.isAfter(earliest))) earliest = pickupAt; + if (earliest != null && request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, request.remark(), operatorId, operatorName); @@ -229,23 +230,25 @@ public class TerminalDrayageService { } private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) { - if (context.base().unloadingCompletedAt() != null) { - value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)); + if (completedUnloading(context) != null) { + value.setEstimatedEmptyReturnAt(returnDeadline(context)); } } private View view(WorkContext context, TerminalDrayageEntity value) { String status = status(context, value); - LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null - : context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS); + LocalDateTime deadline = returnDeadline(context); + Duration remaining = deadline == null ? null : context.directCompletion() == null + ? Duration.between(LocalDateTime.now(clock), deadline) + : Duration.between(clock.instant(), context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS))); LocalDateTime returnedAt = actualEmptyReturnAt(context, value); String warningStatus; long overdueHours = 0; if (returnedAt != null) warningStatus = "RESOLVED"; else if (deadline == null) warningStatus = "NOT_APPLICABLE"; - else if (LocalDateTime.now(clock).isAfter(deadline)) { + else if (remaining.isNegative()) { warningStatus = "OVERDUE"; - overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours()); + overdueHours = Math.max(0, remaining.negated().toHours()); } else warningStatus = "PENDING"; Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24); return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status, @@ -253,14 +256,14 @@ public class TerminalDrayageService { value == null ? null : value.getFleetSupplierName(), value == null ? null : value.getPickupAppointmentAt(), actualPickupAt(context, value), deadline, returnedAt, - context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(), + completedUnloading(context), warning, allowedActions(context, value), context.facts(), value == null ? null : value.getUpdatedAt()); } private List allowedActions(WorkContext context, TerminalDrayageEntity value) { List result = new ArrayList<>(); if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null - && actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL"); + && actualEmptyReturnAt(context, value) == null && completedUnloading(context) == null) result.add("SEND_PICKUP_EMAIL"); CarrierScheduleNode current = currentNode(context); if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() @@ -268,14 +271,14 @@ public class TerminalDrayageService { result.add("SELECT_FLEET"); if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); } - if (context.base().unloadingCompletedAt() != null + if (completedUnloading(context) != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); } private String status(WorkContext context, TerminalDrayageEntity value) { if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; - if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; + if (completedUnloading(context) != null) return "UNLOADED"; if (actualPickupAt(context, value) != null) return "PICKED_UP"; if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; CarrierScheduleNode current = currentNode(context); @@ -300,6 +303,14 @@ public class TerminalDrayageService { return context.facts() == null ? null : node(context.facts().latestActualNode()); } + private LocalDateTime completedUnloading(WorkContext context) { + return context.completedUnloadingAt(); + } + private LocalDateTime returnDeadline(WorkContext context) { + if(context.directCompletion()!=null) return context.directCompletion().deadline(EMPTY_RETURN_WARNING_HOURS); + return completedUnloading(context)==null?null:completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS); + } + private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) { if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt(); return context.base().unloadingCompletedAt(); @@ -334,10 +345,12 @@ public class TerminalDrayageService { private WorkContext context(Context base, ContainerActualFacts facts) { if (base == null || !"ACTIVE".equals(base.recordStatus())) return null; - return new WorkContext(base, facts); + var direct=base.unloadingCompletedAt()==null?unloadingEvidence.directCompletion(base.containerBookingId()):null; + return new WorkContext(base, facts,base.unloadingCompletedAt()!=null?base.unloadingCompletedAt():direct==null?null:direct.localAt(),direct); } - private record WorkContext(Context base, ContainerActualFacts facts) {} + private record WorkContext(Context base, ContainerActualFacts facts,LocalDateTime completedUnloadingAt, + UnloadingEvidenceGuard.DirectCompletion directCompletion) {} private TerminalDrayageEntity requiredAggregate(long id) { TerminalDrayageEntity value = mapper.lock(id); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java new file mode 100644 index 000000000..e20040adb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuard.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.service.EmptyReturnPrerequisite; +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Service; +import java.time.*; + +@Service +public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite { + private final DirectDeliveryMapper mapper; + public UnloadingEvidenceGuard(DirectDeliveryMapper mapper){this.mapper=mapper;} + public record DirectCompletion(LocalDateTime localAt,String timezone) { + public Instant instant(){return localAt.atZone(ZoneId.of(timezone)).toInstant();} + public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();} + } + public DirectCompletion directCompletion(long id) { + var rows=mapper.deliveries(id); + if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null; + return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone())) + .max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null); + } + public LocalDateTime completedUnloading(long id) { + var container=mapper.container(id); + if(container==null || !"ACTIVE".equals(container.recordStatus())) return null; + if(container.warehouseUnloadedAt()!=null) return container.warehouseUnloadedAt(); + var direct=directCompletion(id); + return direct==null?null:direct.localAt(); + } + public void requireUnloaded(long id,LocalDateTime returnedAt) { + var container=mapper.lockContainer(id); + if(container==null) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在"); + LocalDateTime completed=completedUnloading(id); + if(completed==null) throw new BizException(ResultCode.CONFLICT,"海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); + // Original return nodes have no zone. Preserve the existing warehouse-local check, + // but do not compare that unzoned value with another destination's wall clock. + if(container.warehouseUnloadedAt()!=null && returnedAt.isBefore(completed)) + throw new BizException(ResultCode.BAD_REQUEST,"实际还柜时间不能早于卸货完成时间"); + } +} diff --git a/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql new file mode 100644 index 000000000..537561dbf --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql @@ -0,0 +1,19 @@ +-- Current schema for actual destination unloading; never an overseas warehouse receipt. +CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading ( + container_booking_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source', + actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time', + timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone', + received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient', + evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference', + remark VARCHAR(1000) NULL, + version INT NOT NULL DEFAULT 1, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (container_booking_id,inbound_id), + UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), + CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; diff --git a/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml b/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml new file mode 100644 index 000000000..ea5898aaf --- /dev/null +++ b/firstleg/src/main/resources/openapi/appointment-work-source-v1.yaml @@ -0,0 +1,178 @@ +openapi: 3.0.3 +info: {title: Firstleg appointment work authoritative sources, version: 1.0.0} +security: [{InternalServiceToken: []}] +paths: + /internal/firstleg/appointment-work/containers: + get: + summary: Complete actual-sailed container scope + description: Includes missing warehouses, private destinations and stopped tracking tasks. An actual departure or later maritime milestone is required. GET never creates work. Scope-versioned cursors reject changes between pages with 409; consumers restart a full scan. Original local times without a source timezone are never converted to UTC. + parameters: + - {name: after, in: query, schema: {type: string, maxLength: 128}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 200}} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: + data: {$ref: '#/components/schemas/SourcePage'} + '401': {description: Invalid internal service identity} + '409': {description: Scope changed; restart from the first page} + /internal/firstleg/appointment-work/containers/{id}: + get: + summary: Read one authoritative sailed container and physical cargo lineage + parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}] + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {$ref: '#/components/schemas/ContainerSource'}} + '401': {description: Invalid internal service identity} + '404': {description: No active actual-sailed source} + /internal/firstleg/appointment-work/facts: + post: + summary: Read pickup, direct unloading and empty-return facts; no writes + requestBody: + required: true + content: + application/json: + schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/FactRequest'}} + responses: + '200': + description: Exactly one result per requested work key, never a partial success + content: + application/json: + schema: + type: object + properties: {data: {type: array, items: {$ref: '#/components/schemas/Fact'}}} + '400': {description: Invalid type or duplicate identity} + '401': {description: Invalid internal service identity} + '409': {description: Direct requirement does not match physical cargo} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + schemas: + Reference: + type: object + required: [kind, key, label] + properties: + kind: {type: string} + key: {type: string, description: Internal association only} + businessNo: {type: string, nullable: true} + label: {type: string} + SourceReference: + type: object + properties: + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string, nullable: true} + Requirement: + type: object + required: [requirementKey, references, replacesRequirementKeys, sources] + properties: + requirementKey: {type: string, description: 'Immutable INBOUND:'} + batchKey: {type: string, nullable: true} + batchNo: {type: string, nullable: true} + destinationKind: {type: string, nullable: true} + originName: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + timezone: {type: string, nullable: true} + references: {type: array, items: {$ref: '#/components/schemas/Reference'}} + replacesRequirementKeys: {type: array, items: {type: string}} + sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}, description: Empty until Lastleg resolves its canonical source; domestic ORDER references are not tail-leg ORDER keys} + ContainerSource: + type: object + required: [containerBookingId, sourceVersion, establishedFlow, requirements] + properties: + containerBookingId: {type: string} + containerNo: {type: string, nullable: true} + departedAt: {type: string, format: date-time, nullable: true} + departedLocalAt: {type: string, nullable: true, description: Exact actual departure only; source local timestamp without offset} + sailedEvidenceLocalAt: {type: string, nullable: true, description: Actual departure-or-later node proving sailed eligibility; never presented as an actual departure time} + sourceVersion: {type: string} + establishedFlow: {type: string, enum: [UNKNOWN, DIRECT, TRANSFER]} + timezone: {type: string, nullable: true} + requirements: {type: array, items: {$ref: '#/components/schemas/Requirement'}} + SourcePage: + type: object + required: [containers, complete, scopeVersion] + properties: + containers: {type: array, items: {$ref: '#/components/schemas/ContainerSource'}} + nextCursor: {type: string, nullable: true} + complete: {type: boolean} + scopeVersion: {type: string} + ContainerReference: + type: object + properties: + containerBookingId: {type: string} + bookingNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + FactRequest: + type: object + required: [workItemKey, type, flow, containerBookingId] + properties: + workItemKey: {type: string} + type: {type: string, enum: [PICKUP, EMPTY_RETURN, DELIVERY]} + flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]} + containerBookingId: {type: string} + requirementKey: {type: string, nullable: true} + batchKey: {type: string, nullable: true} + tripKey: {type: string, nullable: true} + batchKeys: {type: array, items: {type: string}} + containers: {type: array, items: {$ref: '#/components/schemas/ContainerReference'}} + sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}} + Slot: + type: object + properties: + date: {type: string, format: date} + startTime: {type: string, nullable: true} + endDate: {type: string, format: date, nullable: true} + endTime: {type: string, nullable: true} + timezone: {type: string, nullable: true, description: Null for unzoned original plans; source-local date is retained} + Actual: + type: object + description: At least one time is required. Unzoned source facts contain localOccurredAt only. + properties: + occurredAt: {type: string, format: date-time, nullable: true} + localOccurredAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + Fact: + type: object + required: [workItemKey, fingerprint, impactToken, planExists, planStatus, executionStatus, readPermissions, actionPermissions, blockReasons, missingFields, references] + properties: + workItemKey: {type: string} + fingerprint: {type: string} + impactToken: {type: string, description: First scheduling and first carrier assignment do not increase semantic impact} + planExists: {type: boolean} + planStatus: {type: string, enum: [UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED]} + executionStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]} + target: {$ref: '#/components/schemas/Slot'} + confirmed: {$ref: '#/components/schemas/Slot'} + actual: {$ref: '#/components/schemas/Actual'} + timezone: {type: string, nullable: true} + businessNo: {type: string, nullable: true} + tripKey: {type: string, nullable: true} + readPermissions: {type: array, items: {type: string}} + actionPermissions: {type: array, items: {type: string}} + blockReasons: {type: array, items: {type: string}} + missingFields: {type: array, items: {type: string}} + references: {type: array, items: {$ref: '#/components/schemas/Reference'}} + responsibility: + type: object + nullable: true + properties: {kind: {type: string}, id: {type: string, nullable: true}, name: {type: string}} + action: + type: object + nullable: true + properties: + code: {type: string} + label: {type: string} + targetWorkItemKey: {type: string, nullable: true} + target: + type: object + properties: {kind: {type: string, enum: [FIRSTLEG_CONTAINER]}, containerBookingId: {type: string}} diff --git a/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml b/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml new file mode 100644 index 000000000..30b814db6 --- /dev/null +++ b/firstleg/src/main/resources/openapi/direct-delivery-v1.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: {title: Direct destination unloading evidence, version: 1.0.0} +paths: + /api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries: + get: + summary: Physical loaded inbound sources and immutable direct unloading evidence + x-permission: platform:firstleg:terminal-drayage:list + parameters: [{$ref: '#/components/parameters/Container'}] + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {type: array, items: {$ref: '#/components/schemas/View'}}} + '403': {description: Requires platform identity and list permission} + /api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries/{inboundId}: + post: + summary: Record actual unloading for one complete physically loaded source + description: Locks the physical container. Requires Lastleg's persisted confirmed DIRECT route, actual pickup, a real recipient and receipt reference, an unambiguous IANA local time, and no warehouse unloading or empty-return fact. Route source failure returns 503. Exact retries replay; changed retries conflict. Does not create warehouse stock. Every loaded source must complete before manual empty return. Unzoned pickup/return timestamps are not compared to destination wall-clock times; cross-location chronology cannot be proven without the source zone. Direct completion and its 72-hour reminder use the latest evidence Instant. + x-permission: platform:firstleg:terminal-drayage:direct-unload:confirm + parameters: + - {$ref: '#/components/parameters/Container'} + - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion, actualAt, timezone, receivedBy, evidenceNo] + properties: + expectedVersion: {type: integer, minimum: 0} + actualAt: {type: string, description: Local YYYY-MM-DDTHH:mm:ss without offset} + timezone: {type: string, description: Explicit destination IANA zone} + receivedBy: {type: string, minLength: 1, maxLength: 128} + evidenceNo: {type: string, minLength: 1, maxLength: 128} + remark: {type: string, maxLength: 1000, nullable: true} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: {data: {$ref: '#/components/schemas/View'}} + '400': {description: Invalid source, time or evidence} + '403': {description: Requires platform identity and independent unloading permission} + '409': {description: Invalid business prerequisites, version or replay content} + '503': {description: Cannot verify authoritative route} +components: + parameters: + Container: {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + schemas: + View: + type: object + properties: + requirementKey: {type: string, description: 'INBOUND:, association only, not a visible business number'} + inboundCode: {type: string, nullable: true} + orderNo: {type: string, nullable: true} + destinationName: {type: string, nullable: true} + packageCount: {type: integer} + version: {type: integer, description: Zero until first actual unloading} + actualAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + receivedBy: {type: string, nullable: true} + evidenceNo: {type: string, nullable: true} + remark: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 88df6caf7..b6ac31d49 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -4,7 +4,7 @@ paths: /api/platform/v1/terminal-drayages/calendar: get: summary: 美国目的仓相关的提柜预约日历 - description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。 + description: 平台身份,同时要求预约列表和码头拖车列表权限。已提柜按船节点权威实际提柜日期、尚未提柜按原预约日期查询,改期不移动已执行事实;原预约与实际时间分别返回,不以预计到仓替代。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天,原预约和船节点实际日期候选分别读取1001条检测截断,经各自所属服务读取并按柜去重;返回最多1000条美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。 x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list'] parameters: - {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期} @@ -38,7 +38,7 @@ paths: destinationPortName: {type: string, nullable: true} destinationWarehouseCode: {type: string, nullable: true} fleetSupplierName: {type: string, nullable: true} - pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点} + pickupAppointmentAt: {type: string, nullable: true, description: 原业务当地预约时间,无时区偏移;仅有实际提柜事实时可为空} actualPickupAt: {type: string, nullable: true} '400': {description: 日期范围非法} '403': {description: 非平台身份或权限不足} @@ -82,8 +82,8 @@ paths: responses: {'200': {$ref: '#/components/responses/ViewResponse'}} /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: post: - summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 - description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。 + summary: 海外仓卸柜或全部直送目的仓实际卸货完成后确认实际还柜并推进EMPTY_RETURNED节点 + description: 海外仓卸柜或全部直送目的仓实际卸货完成后开放还柜。无需已有车队或预约;海外仓卸柜可无实际提柜记录。无拖车记录时提交 expectedVersion=0,不补造提柜时间。保留原海外仓卸柜及已有船节点实际提柜的当地时间先后校验;直送目的仓时区不能套用于缺少时区的还柜时间,不比较不同地点的当地钟面值。 x-permission: platform:firstleg:terminal-drayage:empty-return:confirm parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} @@ -173,7 +173,7 @@ components: required: [status, overdueHours, overdueDays] properties: status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]} - deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时} + deadlineAt: {type: string, format: date-time, nullable: true, description: 原海外仓卸柜时间加72小时;直送按最晚真实卸货Instant加72小时后保留该目的地当地时间} overdueHours: {type: integer, format: int64, minimum: 0} overdueDays: {type: integer, format: int64, minimum: 0} TerminalDrayageView: @@ -189,7 +189,7 @@ components: fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} - estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} + estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值;直送72小时按绝对时长计算,时区不明的原海外仓事实保留原规则} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {$ref: '#/components/schemas/Warning'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java new file mode 100644 index 000000000..d1bdd4d0e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/AppointmentWorkSourceMapperTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkSourceMapperTest { + @Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)"); + s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)"); + s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)"); + s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')"); + s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)"); + s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2)"); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var rows=session.getMapper(AppointmentWorkSourceMapper.class).sources(); + assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id()); + assertNull(rows.get(0).destinationWarehouseName());assertEquals(1,rows.get(1).deliveryVersion()); + assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java new file mode 100644 index 000000000..6b8ac26f4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/mapper/PickupActualCalendarMapperTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class PickupActualCalendarMapperTest { + @Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var connection=ds.getConnection();var sql=connection.createStatement()) { + sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')"); + } + var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(CarrierScheduleQueryMapper.class); + var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00"); + assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to)); + try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) { + for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch(); + } + session.clearCache(); + assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java new file mode 100644 index 000000000..c10d85217 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentWorkSourceServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper; +import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkSourceServiceTest { + private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class); + private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class); + private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules()); + private AppointmentWorkSourceMapper.Source source(long id,int version) { + return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L); + } + @Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() { + when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1))); + when(mapper.cargo(anyLong())).thenReturn(List.of()); when(facts.establishedFlow(anyLong())).thenReturn("UNKNOWN"); + var first=service.discover(null,1); + assertFalse(first.complete()); assertNotNull(first.nextCursor()); + assertNull(first.containers().get(0).departedAt()); + assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt()); + var next=service.discover(first.nextCursor(),1); + assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId()); + when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1))); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value()); + } + @Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() { + when(mapper.sources()).thenReturn(List.of(source(1,1))); + when(mapper.cargo(1)).thenReturn(List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12))); + when(facts.establishedFlow(1)).thenReturn("DIRECT"); + var source=service.container(1);var requirement=source.requirements().get(0); + assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind()); + assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null)); + } + @Test void internalControllerFailsClosedBeforeReadingSources() { + var guarded=new InternalAppointmentWorkSourceController(service,"secret"); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value()); + verifyNoInteractions(mapper); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 1351048a4..52e4846b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest { @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; @Mock private CarrierScheduleNodeOutboxService nodeOutbox; + @Mock private EmptyReturnPrerequisite emptyReturnPrerequisite; @InjectMocks private CarrierScheduleQueryService service; private CarrierScheduleAdapterRegistry adapters; @@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest { verify(repository).containerActualFacts(List.of(20L, 21L)); } + @Test + void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() { + org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货")) + .when(emptyReturnPrerequisite).requireUnloaded(20L,NOW); + assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload", + new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( + "EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜"))) + .isInstanceOf(BizException.class).hasMessage("未卸货"); + verifyNoNodePublication(); + } + + private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); } + @Test void rejectsInvalidActualFactBatchBeforeRepositoryAccess() { assertThatThrownBy(() -> service.containerActualFacts(null)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 0b9bc9808..fc8bb1ade 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - int expectedTables = 226; + int expectedTables = 227; long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") .matcher(sql).results().count(); assertThat(actualTables).isEqualTo(expectedTables); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java new file mode 100644 index 000000000..b656df5e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliveryMapperTest.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryMapperTest { + @Test void physicalSourceAggregationAndImmutableEvidenceSurviveActualPersistence() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR,unloading_completed_at TIMESTAMP)"); + s.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)"); + s.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,order_no VARCHAR,destination_warehouse_name VARCHAR,package_count INT,deleted INT)"); + s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))"); + s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')"); + s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)"); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id()); + assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount()); + var at=LocalDateTime.parse("2026-01-01T10:30:00"); + mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin"); + assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key")); + assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt()); + assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin")); + session.rollback(); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java new file mode 100644 index 000000000..1b95bf344 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/DirectDeliverySchemaContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliverySchemaContractTest { + @Test void immutableDirectEvidenceAndIndependentWritePermissionsReachDeploymentBaseline() throws Exception { + String source=Files.readString(Path.of("src/main/resources/db/phase3-direct-delivery-schema.sql")); + String release=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertEquals(definition(source),definition(release)); + assertTrue(source.contains("PRIMARY KEY (container_booking_id,inbound_id)")); + assertTrue(source.contains("UNIQUE KEY uk_direct_unloading_command")); + String permissions=Files.readString(Path.of("../iam/src/main/resources/db/appointment-work-permissions.sql")); + for(String code:java.util.List.of("platform:appointment:manage-work","platform:firstleg:terminal-drayage:direct-unload:confirm")) { + assertTrue(release.contains("'"+code+"'"));assertTrue(permissions.contains("'"+code+"'")); + } + assertTrue(release.contains("VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3))")); + assertTrue(release.contains("VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3))")); + assertTrue(permissions.indexOf("CALL check_appointment_work_permission_identity")r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt()); assertEquals("D0001", rows.get(0).cabinetNo()); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("UPDATE fl_terminal_drayage SET pickup_appointment_at='2026-12-01' WHERE container_booking_id=3"); + s.execute("DELETE FROM fl_terminal_drayage WHERE container_booking_id=2"); + } + session.clearCache(); + rows=session.getMapper(TerminalDrayageMapper.class).calendarByContainers(List.of(1L,2L,3L,4L,5L)); + assertEquals(List.of(1L,2L,3L),rows.stream().map(r->r.containerBookingId()).toList()); + assertNull(rows.get(1).pickupAppointmentAt()); + assertEquals(LocalDateTime.parse("2026-12-01T00:00:00"),rows.get(2).pickupAppointmentAt()); } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java new file mode 100644 index 000000000..13e313cba --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/AppointmentWorkTerminalFactsTest.java @@ -0,0 +1,55 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.terminaldrayage.mapper.*; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkTerminalFactsTest { + private final TerminalDrayageMapper terminal=mock(TerminalDrayageMapper.class); + private final DirectDeliveryMapper direct=mock(DirectDeliveryMapper.class); + private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class); + private final UnloadingEvidenceGuard guard=mock(UnloadingEvidenceGuard.class); + private final AppointmentWorkTerminalFacts adapter=new AppointmentWorkTerminalFacts(terminal,direct,nodes,guard,new ObjectMapper().findAndRegisterModules()); + private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00"); + private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());} + private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());} + private History history(String action){return new History(action,"A","B",1,2,null,7L,"admin",at);} + @Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() { + var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队"); + when(terminal.find(1)).thenReturn(plan); + when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"))); + var fact=adapter.read(request("PICKUP"),source()); + assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date()); + assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE")); + when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"),history("SCHEDULE_PICKUP"))); + assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken()); + } + @Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() { + var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人"); + when(terminal.find(1)).thenReturn(plan); + when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at))); + var fact=adapter.read(request("EMPTY_RETURN"),source()); + assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt()); + assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo()); + } + @Test void directDeliveryReferencesOnlyItsOwnPhysicalSourceAndCustomer() { + var own=List.of(new Reference("CUSTOMER","7",null,"客户A"),new Reference("INBOUND","2","IN2","入仓单")); + var other=List.of(new Reference("CUSTOMER","8",null,"客户B"),new Reference("INBOUND","3","IN3","入仓单")); + var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of( + new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()), + new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of()))); + when(direct.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h"))); + var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of()); + var fact=adapter.read(request,source); + assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java index 74175e9c0..73a53dcf7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -132,7 +132,7 @@ class ContainerWorkbenchServiceTest { var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); var realDrayages = new TerminalDrayageService(mapper, - mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class)); + mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class)); ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); ContainerBookingEntity container = container(); container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0)); @@ -176,7 +176,7 @@ class ContainerWorkbenchServiceTest { var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); var realDrayages = new TerminalDrayageService(mapper, mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, - new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class)); + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class)); ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); ContainerBookingEntity container = container(); LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java new file mode 100644 index 000000000..ecdd95a33 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryRouteGuardTest.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.client.AppointmentWorkRouteClient; +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryRouteGuardTest { + @Test void onlyConfirmedDirectRouteCanExecuteAndRemoteFailureIsNotUnknownSuccess() { + var client=mock(AppointmentWorkRouteClient.class);var guard=new DirectDeliveryRouteGuard(client,"token"); + for(String flow:new String[]{"UNKNOWN","TRANSFER"}) { + when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route(flow))); + assertEquals(409,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value()); + } + when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route("DIRECT"))); + assertDoesNotThrow(()->guard.requireDirect(1)); + when(client.route("token",1)).thenThrow(new IllegalStateException("offline")); + assertEquals(503,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java new file mode 100644 index 000000000..c7f3b3701 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/DirectDeliveryServiceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class DirectDeliveryServiceTest { + private final DirectDeliveryMapper mapper=mock(DirectDeliveryMapper.class); + private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class); + private final DirectDeliveryService service=new DirectDeliveryService(mapper,nodes,new ObjectMapper().findAndRegisterModules(),mock(DirectDeliveryRouteGuard.class)); + private final LocalDateTime at=LocalDateTime.parse("2026-01-03T12:00:00"); + private DirectDeliveryMapper.Delivery pending(){return new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,0,null,null,null,null,null,null,null);} + private DirectDeliveryService.Request request(){return new DirectDeliveryService.Request(0,at,"America/Los_Angeles","收货人","POD-2","直送签收");} + @Test void recordsOnlyActualLoadedSourceAfterPickupAndReplaysWithoutDuplicateWrite() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null)); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + // Unknown pickup zone: a numerically later source-local clock is not evidence + // that the explicitly Los Angeles destination actual occurred first. + when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"READY","PICKED_UP",at.plusHours(1),null,null,null,null,at.plusHours(1),null))); + doAnswer(inv->{ + when(mapper.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,1,at,"America/Los_Angeles","收货人","POD-2","直送签收","k",inv.getArgument(8)))); + return 1; + }).when(mapper).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString()); + assertEquals(1,service.record(1,2,7,"admin","k",request()).version()); + assertEquals(1,service.record(1,2,7,"admin","k",request()).version()); + verify(mapper,times(1)).insert(eq(1L),eq(2L),eq(at),eq("America/Los_Angeles"),eq("收货人"),eq("POD-2"),any(),eq("k"),anyString(),eq(7L),eq("admin")); + } + @Test void refusesForeignSourceAndWarehouseReceiptWithoutWriting() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",at.minusDays(1))); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + assertThrows(BizException.class,()->service.record(1,99,7,"a","k",request())); + assertThrows(BizException.class,()->service.record(1,2,7,"a","k",request())); + verifyNoInteractions(nodes); + verify(mapper,never()).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString()); + } + @Test void refusesAmbiguousLocalTimeInsteadOfGuessingAnOffset() { + when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null)); + when(mapper.deliveries(1)).thenReturn(List.of(pending())); + var ambiguous=new DirectDeliveryService.Request(0,LocalDateTime.parse("2025-11-02T01:30:00"),"America/Los_Angeles","收货人","POD",null); + assertThrows(BizException.class,()->service.record(1,2,7,"a","k",ambiguous));verifyNoInteractions(nodes); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java index 31613517a..14deae53a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java @@ -46,8 +46,35 @@ class TerminalDrayageCalendarServiceTest { when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses); } @Test void reportsTruncationInsteadOfClaimingCompleteResults() { - when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W"))); + when(mapper.calendar(any(),any(),anyInt())).thenReturn(java.util.stream.LongStream.rangeClosed(1,1001).mapToObj(id->row(id,"US-W")).toList()); when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size()); } + @Test void mergesActualMonthWithoutMovingExecutedWorkWhenPlansChangeOrDuplicatingTheContainer() { + when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W"),row(2,"US-W"))); + when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(2L,3L)); + var movedPlan=new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W","Fleet",to.plusDays(20).atStartOfDay(),null); + when(mapper.calendarByContainers(List.of(2L,3L))).thenReturn(List.of(row(2,"US-W"),movedPlan)); + when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); + when(shipping.containerActualFacts(List.of(1L,2L,3L))).thenReturn(List.of( + fact(1L,to.atStartOfDay()),fact(2L,from.atStartOfDay()),fact(3L,from.plusDays(1).atStartOfDay()))); + var result=service.calendar(from,to); + assertEquals(List.of(2L,3L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList()); + assertEquals(to.plusDays(20).atStartOfDay(),result.records().get(1).pickupAppointmentAt()); + assertEquals(from.plusDays(1).atStartOfDay(),result.records().get(1).actualPickupAt()); + assertFalse(result.truncated()); + } + private com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts fact(long id,java.time.LocalDateTime at) { + return new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(id,null,null,null,null,null,null,null,null,null,at,null); + } + @Test void includesAnActualPickupWithoutInventingAnAppointment() { + when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(3L)); + when(mapper.calendarByContainers(List.of(3L))).thenReturn(List.of(new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W",null,null,null))); + when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US")))); + when(shipping.containerActualFacts(List.of(3L))).thenReturn(List.of(fact(3L,from.atStartOfDay()))); + var result=service.calendar(from,to); + assertEquals(1,result.records().size()); + assertNull(result.records().get(0).pickupAppointmentAt()); + assertEquals(from.atStartOfDay(),result.records().get(0).actualPickupAt()); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index c25510484..c5e6ef656 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -47,6 +47,7 @@ class TerminalDrayageServiceTest { private CarrierScheduleQueryService carrierSchedules; private TerminalDrayageService service; private TerminalDrayageMailService mails; + private UnloadingEvidenceGuard unloadingEvidence; private Deque facts; @BeforeEach @@ -60,7 +61,8 @@ class TerminalDrayageServiceTest { return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst()); }); mails = mock(TerminalDrayageMailService.class); - service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails); + unloadingEvidence = mock(UnloadingEvidenceGuard.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails, unloadingEvidence); service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); } @@ -115,7 +117,7 @@ class TerminalDrayageServiceTest { assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) .isInstanceOf(BizException.class) - .hasMessage("海外卸柜收货完成后才能确认还柜"); + .hasMessage("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜"); verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); } @@ -138,6 +140,19 @@ class TerminalDrayageServiceTest { assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); } + @Test + void directReturnWarningUsesDestinationInstantInsteadOfServerWallClock() { + when(mapper.context(11L)).thenReturn(context("PICKED_UP", null)); + when(mapper.find(11L)).thenReturn(row(3)); + when(unloadingEvidence.directCompletion(11L)).thenReturn(new UnloadingEvidenceGuard.DirectCompletion( + LocalDateTime.parse("2026-09-01T22:00:00"), "America/Los_Angeles")); + var result=service.detail(11L); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.parse("2026-09-04T22:00:00")); + assertThat(result.emptyReturnWarning().status()).isEqualTo("PENDING"); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T06:00:00Z"), ZoneOffset.ofHours(9))); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isEqualTo(1); + } + @Test void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() { LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java new file mode 100644 index 000000000..f342096bd --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/UnloadingEvidenceGuardTest.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class UnloadingEvidenceGuardTest { + @Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() { + var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper); + var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null); + when(mapper.container(1)).thenReturn(container);when(mapper.lockContainer(1)).thenReturn(container); + var at=LocalDateTime.parse("2026-10-10T12:00:00"); + var first=new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,at,"America/Los_Angeles","张三","POD2",null,"k","h"); + var pending=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,0,null,null,null,null,null,null,null); + when(mapper.deliveries(1)).thenReturn(List.of(first,pending)); + assertNull(guard.completedUnloading(1));assertThrows(BizException.class,()->guard.requireUnloaded(1,at.plusDays(1))); + var second=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,at.plusHours(1),"America/Los_Angeles","李四","POD3",null,"k3","h3"); + when(mapper.deliveries(1)).thenReturn(List.of(first,second)); + assertEquals(at.plusHours(1),guard.completedUnloading(1)); + assertDoesNotThrow(()->guard.requireUnloaded(1,at)); // Return has no source zone. + assertDoesNotThrow(()->guard.requireUnloaded(1,at.plusDays(1))); + var warehouse=new DirectDeliveryMapper.Container(1,"ACTIVE",at.plusHours(1)); + when(mapper.container(1)).thenReturn(warehouse);when(mapper.lockContainer(1)).thenReturn(warehouse); + assertThrows(BizException.class,()->guard.requireUnloaded(1,at)); + } + @Test void comparesDestinationEvidenceByInstantAndMeasuresSeventyTwoHoursAcrossDst() { + var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper); + var ny=LocalDateTime.parse("2026-10-10T12:00:00"); + var la=LocalDateTime.parse("2026-10-10T10:00:00"); + when(mapper.deliveries(1)).thenReturn(List.of( + new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,ny,"America/New_York","a","P2",null,"k2","h2"), + new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,la,"America/Los_Angeles","a","P3",null,"k3","h3"))); + assertEquals(la,guard.directCompletion(1).localAt()); + var beforeFallback=new UnloadingEvidenceGuard.DirectCompletion(LocalDateTime.parse("2026-10-31T12:00:00"),"America/Los_Angeles"); + assertEquals(LocalDateTime.parse("2026-11-03T11:00:00"),beforeFallback.deadline(72)); + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 89298259e..885f6cd5f 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -85,12 +85,56 @@ ], "sliceSpecificRules": { "customer-statistics": { - "allowedOutgoing": ["shared-technical"], + "allowedOutgoing": [ + "shared-technical" + ], "allowedIncoming": [] }, "pricebook-query": { "allowedOutgoing": [], - "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] + "allowedIncoming": [ + "booking-customs-trucking", + "measurement-pricing", + "split-loading" + ] } - } + }, + "reviewedClassDependencies": [ + { + "source": "com.baiyue.firstleg.controller.InternalOverseasContainerEventController", + "target": "com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer", + "reason": "海外事件HTTP适配器按事件类型派发到暂扣消费者;入仓数据仍由消费者维护。" + }, + { + "source": "com.baiyue.firstleg.loading.service.LoadingDeliveryService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "装柜实物完成时按真实分配刷新费用,调用既有计价应用服务,不读取计价持久化。" + }, + { + "source": "com.baiyue.firstleg.service.LoadingListService", + "target": "com.baiyue.firstleg.domain.PriceBookChannelCatalog", + "reason": "分柜按当前有效渠道目录校验,复用纯领域目录,不访问价格簿实现。" + }, + { + "source": "com.baiyue.firstleg.service.LoadingListService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "装柜分配变动触发既有计价服务按客户重算,费用冻结门槛保留在原服务。" + }, + { + "source": "com.baiyue.firstleg.service.SmartLoadingService", + "target": "com.baiyue.firstleg.service.FbaPricingService", + "reason": "智能排柜分配后调用原计价服务守恒重算,不跨域修改费用表。" + } + ], + "reviewedPackageCycles": [ + { + "packages": [ + "com.baiyue.firstleg.loading.bridge.service", + "com.baiyue.firstleg.loading.service", + "com.baiyue.firstleg.oceanbooking.service", + "com.baiyue.firstleg.service" + ], + "reason": "既有全局service包混合了入仓暂扣、装柜费用与订舱文档的应用编排。本轮不拆分这些无关既有服务;仅冻结当前四包SCC,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。" + } + ] } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 62248107c..d72f2325f 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -18,6 +18,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下经营总览、销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;四个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 | +| 预约负责人权威目录 | `controller/InternalAuthorizationController.java` 的 `/internal/iam/platform-owners/{authUserId}`、`service/PlatformOwnerDirectoryService.java`;仅有效平台成员与 PLATFORM 角色,返回 Auth 主体 ID 和权威姓名;新工作安排及直送卸货写权限种子 `src/main/resources/db/appointment-work-permissions.sql` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | | 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java index 46a0f711c..2854f8200 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalAuthorizationController.java @@ -15,6 +15,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import com.baiyue.iam.service.PlatformOwnerDirectoryService; +import com.baiyue.iam.dto.PlatformOwner; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -27,6 +31,7 @@ import java.security.MessageDigest; public class InternalAuthorizationController { @Resource private AuthorizationSnapshotService service; + @Resource private PlatformOwnerDirectoryService owners; @Value("${baiyue.security.internal-service-token}") private String internalTokenValue; private byte[] internalToken; @@ -49,4 +54,13 @@ public class InternalAuthorizationController { } return ApiResponse.success(service.load(request)); } + + @GetMapping("/platform-owners/{authUserId}") + public ApiResponse owner(@RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @PathVariable long authUserId) { + byte[] supplied=token==null?new byte[0]:token.getBytes(StandardCharsets.UTF_8); + if(internalToken==null || !MessageDigest.isEqual(internalToken,supplied)) + throw new BizException(ResultCode.FORBIDDEN,"无效的服务间凭据"); + return ApiResponse.success(owners.require(authUserId)); + } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java new file mode 100644 index 000000000..1ad582e7b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformOwner.java @@ -0,0 +1,4 @@ +package com.baiyue.iam.dto; + +/** Auth subject identity, not the IAM profile or membership primary key. */ +public record PlatformOwner(String id,String name) { } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 42a525c93..d98f433e4 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -11,6 +11,18 @@ import java.util.List; */ @Mapper public interface AuthorizationQueryMapper { + @Select(""" + SELECT CONCAT('',u.auth_user_id) id,u.display_name name + FROM iam_user u + WHERE u.auth_user_id=#{authUserId} AND u.status='ACTIVE' AND u.deleted=0 + AND u.display_name IS NOT NULL AND TRIM(u.display_name)<>'' + AND EXISTS ( + SELECT 1 FROM iam_membership m JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0 + JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0 + WHERE m.user_id=u.id AND m.tenant_id=0 AND m.status='ACTIVE' AND m.deleted=0) + """) + com.baiyue.iam.dto.PlatformOwner findActivePlatformOwner(@Param("authUserId") long authUserId); + @Select(""" SELECT r.role_code AS roleCode, p.permission_code AS permissionCode, COALESCE(sv.permission_version,0) AS permissionVersion, diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java new file mode 100644 index 000000000..a7082a489 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformOwnerDirectoryService.java @@ -0,0 +1,19 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.PlatformOwner; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.springframework.stereotype.Service; + +@Service +public class PlatformOwnerDirectoryService { + private final AuthorizationQueryMapper mapper; + public PlatformOwnerDirectoryService(AuthorizationQueryMapper mapper){this.mapper=mapper;} + public PlatformOwner require(long authUserId) { + if(authUserId<=0) throw new BizException(ResultCode.BAD_REQUEST,"平台操作人员标识无效"); + var owner=mapper.findActivePlatformOwner(authUserId); + if(owner==null) throw new BizException(ResultCode.NOT_FOUND,"未找到有效平台操作人员"); + return owner; + } +} diff --git a/iam/src/main/resources/db/appointment-work-permissions.sql b/iam/src/main/resources/db/appointment-work-permissions.sql new file mode 100644 index 000000000..0f5279dcf --- /dev/null +++ b/iam/src/main/resources/db/appointment-work-permissions.sql @@ -0,0 +1,45 @@ +USE baiyue_iam; + +-- Reject identity collisions before any permission or grant is changed. +DROP PROCEDURE IF EXISTS check_appointment_work_permission_identity; +DELIMITER $$ +CREATE PROCEDURE check_appointment_work_permission_identity() +BEGIN + IF EXISTS(SELECT 1 FROM iam_permission WHERE + (id=5107283 AND permission_code<>'platform:appointment:manage-work') + OR (id=5102213 AND permission_code<>'platform:firstleg:terminal-drayage:direct-unload:confirm') + OR (permission_code='platform:appointment:manage-work' AND id<>5107283) + OR (permission_code='platform:firstleg:terminal-drayage:direct-unload:confirm' AND id<>5102213)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Appointment work permission identity conflict'; + END IF; +END$$ +DELIMITER ; +CALL check_appointment_work_permission_identity(); +DROP PROCEDURE check_appointment_work_permission_identity; + +-- Two independent write capabilities; appointment/list remains read-only. +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,0), + (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),status='ACTIVE',deleted=0; + +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) +FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.id IN (5107283,5102213) +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0; + +DROP PROCEDURE IF EXISTS assert_appointment_work_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_appointment_work_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND ((id=5107283 AND permission_code='platform:appointment:manage-work' AND parent_id=5107280) + OR (id=5102213 AND permission_code='platform:firstleg:terminal-drayage:direct-unload:confirm' AND parent_id=5102200))) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Appointment work permission identity conflict'; + END IF; +END$$ +DELIMITER ; +CALL assert_appointment_work_permissions(); +DROP PROCEDURE assert_appointment_work_permissions; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 28e223b91..cd0957681 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -17,6 +17,28 @@ tags: - name: RBAC description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 paths: + /internal/iam/platform-owners/{authUserId}: + get: + summary: Read active platform operator identity and authoritative display name + description: Internal service identity only. Auth subject ID is distinct from IAM profile and membership IDs. Requires active undeleted user, platform membership and PLATFORM role; no caller-supplied name fallback. + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: [id, name] + properties: {id: {type: string}, name: {type: string}} + '403': {description: Invalid internal service credential} + '404': {description: No eligible active platform operator} /api/merchant/v1/iam/profile: get: tags: [Merchant Tenant / 商户企业] diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java new file mode 100644 index 000000000..f1c7573a9 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformOwnerDirectoryControllerTest.java @@ -0,0 +1,21 @@ +package com.baiyue.iam.controller; + +import com.baiyue.iam.service.PlatformOwnerDirectoryService; +import com.baiyue.iam.dto.PlatformOwner; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class PlatformOwnerDirectoryControllerTest { + @Test void internalIdentityIsRequiredBeforeReadingDirectory() { + var service=mock(PlatformOwnerDirectoryService.class);var controller=new InternalAuthorizationController(); + String token="test-internal-token-at-least-32-characters"; + ReflectionTestUtils.setField(controller,"owners",service);ReflectionTestUtils.setField(controller,"internalTokenValue",token); + controller.initializeInternalToken(); + assertThrows(BizException.class,()->controller.owner(null,101));verifyNoInteractions(service); + when(service.require(101)).thenReturn(new PlatformOwner("101","运营甲")); + assertEquals("运营甲",controller.owner(token,101).data().name()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java b/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java new file mode 100644 index 000000000..e769cc5e1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/mapper/PlatformOwnerDirectoryMapperTest.java @@ -0,0 +1,33 @@ +package com.baiyue.iam.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class PlatformOwnerDirectoryMapperTest { + @Test void returnsAuthSubjectAndNameOnlyForActivePlatformMembershipAndRole() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try(var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE iam_user(id BIGINT,auth_user_id BIGINT,display_name VARCHAR,status VARCHAR,deleted INT)"); + s.execute("CREATE TABLE iam_membership(id BIGINT,user_id BIGINT,tenant_id BIGINT,status VARCHAR,deleted INT)"); + s.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + s.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR,status VARCHAR,deleted INT)"); + s.execute("INSERT INTO iam_user VALUES(1,101,'运营甲','ACTIVE',0),(2,102,'客户乙','ACTIVE',0),(3,103,'停用丙','DISABLED',0),(4,104,'失效成员','ACTIVE',0)"); + s.execute("INSERT INTO iam_membership VALUES(11,1,0,'ACTIVE',0),(12,2,88,'ACTIVE',0),(13,3,0,'ACTIVE',0),(14,4,0,'DISABLED',0)"); + s.execute("INSERT INTO iam_membership_role VALUES(11,0,21),(12,88,21),(13,0,21),(14,0,21)"); + s.execute("INSERT INTO iam_role VALUES(21,0,'PLATFORM','ACTIVE',0)"); + } + var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AuthorizationQueryMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var mapper=session.getMapper(AuthorizationQueryMapper.class); + assertEquals("101",mapper.findActivePlatformOwner(101).id());assertEquals("运营甲",mapper.findActivePlatformOwner(101).name()); + assertNull(mapper.findActivePlatformOwner(1));assertNull(mapper.findActivePlatformOwner(102)); + assertNull(mapper.findActivePlatformOwner(103));assertNull(mapper.findActivePlatformOwner(104)); + } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index c7cdc23e9..4314ad8fe 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -150,6 +150,8 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)).stream() .filter(code -> code.startsWith("platform:")) .forEach(moduleCatalog::add); + codes(new ClassPathResource("db/procurement-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)).forEach(moduleCatalog::add); Set serviceCatalog = new LinkedHashSet<>(); serviceCatalog.addAll(codes(repositoryFile( "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 0e811be40..404ea68be 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -19,8 +19,10 @@ test('selfoperated analytics is independent of customer outbound and inventory s assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound'))); assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics')); assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics' - || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }, { source: 'shared-technical', target: 'selfoperated-analytics', count: 1 }]); + || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 2 }, { source: 'shared-technical', target: 'selfoperated-analytics', count: 1 }]); assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{ + source: prefix + 'diagnostic.QuantityDiagnosticController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', + }, { source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', }]); assert.deepEqual(report.packageSccs.filter(cycle => cycle.some(pkg => pkg.startsWith(prefix))), []); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapper.java index 03b0e15cb..f1a091b5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapper.java @@ -13,7 +13,7 @@ public interface AppointmentBatchPreviewMapper { EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixedDestinations, EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id - WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS activeAppointment + WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS activeAppointment FROM ll_order o WHERE o.id IN #{id} """ + "") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java index d879d084e..fd9a8edb8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java @@ -12,14 +12,17 @@ import java.util.stream.Collectors; public class AppointmentBatchPreviewService { private final AppointmentSourceService sources; private final AppointmentBatchPreviewMapper mapper; - public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper) { - this.sources=sources;this.mapper=mapper; + private final AppointmentCargoVerificationService verification; + public AppointmentBatchPreviewService(AppointmentSourceService sources,AppointmentBatchPreviewMapper mapper,AppointmentCargoVerificationService verification) { + this.sources=sources;this.mapper=mapper;this.verification=verification; } public record Preview(AppointmentPlanningPolicy.Group group,String originWarehouseName, String destinationWarehouseCode,String destinationTimezone,List sources, - List missingFields) { } + List missingFields,List cargoVerification) { } public Preview preview(List refs) { var resolved=sources.resolveBatch(refs); + var verified=resolved.stream().map(verification::inspect).toList(); + var bySource=verified.stream().collect(Collectors.toMap(v->v.source().sourceKey(),Function.identity())); var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); var flags=ids.isEmpty()?java.util.Map.of():mapper.flags(ids).stream() .collect(Collectors.toMap(AppointmentBatchPreviewMapper.Flags::orderId,Function.identity())); @@ -30,17 +33,19 @@ public class AppointmentBatchPreviewService { else if(state!=null) { if(state.detained()) reason="货物暂扣,请先处理后再合批"; else if(state.mixedDestinations()) reason="货物含多个目的地,请先核对明细"; - else if(state.activeAppointment()) reason="货物已有有效预约,请编辑原预约"; } + var context=bySource.get(source.sourceKey()); + if(context.sourceChanged() || context.unknownAllocationCount()>0 || context.effectivePackages()!=null && context.allocatedPackages()>context.effectivePackages()) reason="来源或已有分配待核实,请先处理原安排"; + else if(context.availablePackages()!=null && context.availablePackages()<=0) reason="原来源箱数已全部分配,请继续已有批次"; // Arrival/warehouse ETA is not a cargo-ready window. Until an authoritative producer exists it stays unknown. return new AppointmentPlanningPolicy.Cargo(source.sourceKey(),source.customerId(),source.originWarehouseId(), - source.destinationKey(),"US","US",source.packages(),null,null,reason==null,reason); + source.destinationKey(),"US","US",context.availablePackages()==null?null:Math.toIntExact(context.availablePackages()),null,null,reason==null,reason); }).toList(); try { var group=AppointmentPlanningPolicy.group(cargo); return new Preview(group,resolved.get(0).originWarehouseName(),resolved.get(0).destinationWarehouseCode(), sources.destinationTimezone(resolved.get(0).destinationWarehouseCode()),resolved, - group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED")); + group.unknownQuantitySources()>0?List.of("READY_WINDOW_UNVERIFIED","CARGO_QUANTITY_REVIEW"):List.of("READY_WINDOW_UNVERIFIED"),verified); } catch(IllegalArgumentException error) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST,error.getMessage(),error); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java index d19787cd8..0ebf79da8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java @@ -35,7 +35,7 @@ public interface AppointmentCargoMapper { g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained, EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan, EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id - WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment, + WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment, EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixed_destinations """ + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java new file mode 100644 index 000000000..29bd0dd38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** Append-only appointment evidence; never updates an order or its declared cargo. */ +@Data +@TableName("ll_appointment_cargo_verification") +public class AppointmentCargoVerification { + @TableId private Long id; + private String customerId; + private String sourceKey; + private String destinationKey; + private String sourceVersion; + private Integer sourcePackages; + private Integer version; + private Integer packageCount; + private String shipmentReferencesJson; + private String remark; + private String operatorId; + private String operatorName; + private String commandKey; + private String requestHash; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java new file mode 100644 index 000000000..28cc4049e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/cargo-verification") +public class AppointmentCargoVerificationController { + private final AppointmentCargoVerificationService service; + public AppointmentCargoVerificationController(AppointmentCargoVerificationService service) {this.service=service;} + + @GetMapping + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey,@RequestParam String customerId,@RequestParam(required=false) String destinationKey) { + AppointmentPlanningController.requirePlatform(user); + return ApiResponse.success(service.get(sourceKey,customerId,destinationKey)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey,@RequestParam String customerId,@Valid @RequestBody AppointmentCargoVerificationService.Save request) { + AppointmentPlanningController.requirePlatform(user); + return ApiResponse.success(service.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null?user.username():user.displayName())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java new file mode 100644 index 000000000..cef46bf6c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface AppointmentCargoVerificationMapper extends BaseMapper { + /** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */ + @Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE") + @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) + Long lockSourceOrder(@Param("orderId") Long orderId); + + @Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} ORDER BY version DESC LIMIT 1") + AppointmentCargoVerification latest(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey); + + @Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}") + AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key); + + record Allocation(long packages, long unknownItems) { } + @Select(""" + SELECT COALESCE(SUM(package_count),0) AS packages, + COUNT(*)-COUNT(package_count) AS unknownItems + FROM ll_inbound_fba_appointment_item + WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND active=1 + """) + Allocation allocated(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java new file mode 100644 index 000000000..1e25b5cb6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; + +@Service +public class AppointmentCargoVerificationService { + private final AppointmentSourceService sources; + private final AppointmentCargoVerificationMapper mapper; + private final ObjectMapper json; + + public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, + ObjectMapper json) { + this.sources=sources; this.mapper=mapper; this.json=json; + } + public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion, + @Min(0) int expectedVersion, @Positive Integer packageCount, + @NotNull @Size(max=100) List<@NotBlank @Size(max=128) String> shipmentReferences, + @Size(max=1000) String remark, @NotBlank @Size(max=64) String idempotencyKey) { } + public record Verification(int version, String sourceVersion, String destinationKey, Integer sourcePackages, + Integer packageCount, List shipmentReferences, String remark, + String operatorName, Instant verifiedAt) { } + public record Context(AppointmentSourceService.Source source, Verification verification, + long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, + Long availablePackages, boolean sourceChanged, List allowedActions, + List blockReasons) { } + + @Transactional(readOnly=true) + public Context get(String sourceKey,String customerId,String destinationKey) { + var source=sources.resolve(sourceKey,customerId,destinationKey); + return inspect(source); + } + /** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ + public Context inspect(AppointmentSourceService.Source source) { + var row=mapper.latest(source.customerId(),source.sourceKey()); + var allocation=mapper.allocated(source.customerId(),source.sourceKey()); + boolean changed=row!=null && changed(row,source); + Integer total=row==null?source.packages():row.getPackageCount(); + var reasons=new ArrayList<>(source.blockReasons()); + if(changed) reasons.add("原来源资料已变化,请重新核实预约货物资料"); + if(total==null || total<=0) reasons.add("箱数尚未核实"); + if(allocation.unknownItems()>0) reasons.add("已有预约的分配箱数待核实"); + if(total!=null && allocation.packages()>total) reasons.add("已有分配超过当前货量,请先复核原安排"); + Long available=changed || total==null || total<=0 || allocation.unknownItems()>0 || allocation.packages()>total + ?null:total.longValue()-allocation.packages(); + // Actions describe this endpoint's business capability; controller permissions remain mandatory. + var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.of(); + return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total, + available,changed,actions,List.copyOf(reasons)); + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) { + validate(request,operatorId); + String hash=hash(customerId,sourceKey,operatorId,request); + var replay=mapper.command(customerId,sourceKey,request.idempotencyKey()); + if(replay!=null) return replay(replay,hash); + var refs=List.of(new AppointmentCoverageService.SourceRef(customerId,sourceKey,request.destinationKey())); + sources.lockForWrite(refs); + var before=sources.resolveBatch(refs).get(0); + if(before.orderId()!=null && mapper.lockSourceOrder(before.orderId())==null) throw conflict("原货源已变化,请重新查询"); + var source=sources.resolveBatch(refs).get(0); + if(!Objects.equals(before.orderId(),source.orderId())) throw conflict("货源关联已变化,请重新查询"); + // The order/booking lock serializes verification with allocation and invalidates pre-lock facts. + replay=mapper.command(customerId,sourceKey,request.idempotencyKey()); + if(replay!=null) return replay(replay,hash); + if(!source.canFollowup()) throw conflict("当前货源已完成、取消或尚未具备安排资格"); + if(!Objects.equals(source.sourceVersion(),request.expectedSourceVersion())) throw conflict("原来源版本已变化,请重新核实"); + var latest=mapper.latest(customerId,sourceKey); + if((latest==null?0:latest.getVersion())!=request.expectedVersion()) throw conflict("货物核实资料已更新,请刷新后提交"); + Integer count=request.packageCount(); + if(count!=null && source.packages()!=null && count>source.packages()) throw conflict("核实箱数不能超过原来源箱数,请先核对来源"); + var allocation=mapper.allocated(customerId,sourceKey); + if(allocation.packages()>0 && (count==null || count>(){}),row.getRemark(), + row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC)); + } catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料不可读",error);} + } + private String hash(String customer,String source,String operator,Save request) { + try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customer+"\n"+source+"\n"+operator+"\n"+encode(request)).getBytes(StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException error) {throw new IllegalStateException(error);} + } + private String encode(Object value) { + try {return json.writeValueAsString(value);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实资料无法保存",error);} + } + private static void validate(Save request,String operator) { + if(request==null || clean(operator)==null || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64 + || clean(request.destinationKey())==null || request.destinationKey().length()>160 || request.expectedVersion()<0 + || request.packageCount()!=null && request.packageCount()<=0 || request.shipmentReferences()==null || request.shipmentReferences().size()>100 + || request.shipmentReferences().stream().anyMatch(ref->clean(ref)==null || ref.length()>128) + || request.remark()!=null && request.remark().length()>1000 || request.expectedSourceVersion()!=null && request.expectedSourceVersion().length()>160) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请核对货物箱数、货件号及保存版本"); + } + private static String clean(String value) {return value==null || value.isBlank()?null:value.trim();} + private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java index 18f87dec6..2f31d1832 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java @@ -19,7 +19,7 @@ public interface AppointmentCoverageMapper { record Fact(Long orderId, String customerId, String appointmentNo, String status, LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt, Long itemId, Integer packages, String warehouseCode,String sourceKey,String sourceVersion, - String confirmationNo,String timezone) { } + String confirmationNo,String timezone,Integer cargoVerificationVersion) { } @Select({""}) List appointments(@Param("sources") List sources); + + @Select({""}) + List verifications(@Param("sources") List sources); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java index edff9311b..bd0212c61 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java @@ -49,6 +49,7 @@ public class AppointmentCoverageService { var orders=unique.isEmpty() ? List.of() : mapper.sources(unique); var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s)); var facts=mapper.appointments(requested); + var verified=mapper.verifications(requested).stream().collect(Collectors.toMap(v->key(v.getCustomerId(),v.getSourceKey()),v->v)); var bySource=facts.stream().collect(Collectors.groupingBy(f -> key(f.customerId(),f.sourceKey()))); var output=new ArrayList(); for (var source:requested) { @@ -75,25 +76,30 @@ public class AppointmentCoverageService { output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue; } var distinct=new LinkedHashMap(); + var evidence=verified.get(key(source.customerId(),source.sourceKey())); + String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()); + Integer packages=evidence==null?order.packages():evidence.getPackageCount(); var seen=new HashSet(); long confirmed=0, covered=0; boolean invalid=false, changed=false; + if(evidence!=null) changed=!Objects.equals(currentVersion,evidence.getSourceVersion()) + || !Objects.equals(source.destinationKey(),evidence.getDestinationKey()) || !Objects.equals(order.packages(),evidence.getSourcePackages()); for (var fact:relevant) { if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; } covered+=fact.packages(); boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone()); if (success) confirmed+=fact.packages(); distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone())); - String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()); changed|=!Objects.equals(currentVersion,fact.sourceVersion()); + if(evidence!=null) changed|=!Objects.equals(evidence.getVersion(),fact.cargoVerificationVersion()); } var missing=new ArrayList(); String status; - if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } + if (invalid || packages==null || packages<=0 || covered>packages) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); } - else if (covered"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); - if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES"); + if (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES"); if (changed) missing.add("SOURCE_CHANGED_REVIEW"); output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); @@ -106,4 +112,13 @@ public class AppointmentCoverageService { private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; } private static String version(Number version) { return version==null ? null : version.toString(); } private static boolean blank(String value) { return value==null || value.isBlank(); } + private static boolean hasReferences(String json) { + if(blank(json)) return false; + try { + var refs=new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + if(!refs.isArray() || refs.isEmpty()) return false; + for(var ref:refs) if(!ref.isTextual() || ref.asText().isBlank()) return false; + return true; + } catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约核实资料不可读",error);} + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java index c87933150..752a0c99f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -29,6 +29,20 @@ public class AppointmentPlanningController { } public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { } + @GetMapping("/sources/resolve-order") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse resolveOrder(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam Long orderId, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) { + requirePlatform(user); + if(orderId==null || orderId<=0 || customerId==null || customerId.isBlank() || customerId.length()>128) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实订单及所属客户"); + var ref=sources.canonicalOrders(java.util.List.of(new AppointmentExternalSourceGateway.OrderRef(customerId,orderId))).get(0); + var source=sources.resolve(ref.sourceKey(),customerId,destinationKey); + if(!orderId.equals(source.orderId())) throw new ResponseStatusException(HttpStatus.CONFLICT,"订单与原来源关联已变化,请刷新后核对"); + var result=coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey()))); + return ApiResponse.success(new Resolved(source,result.get(0))); + } + @GetMapping("/sources/resolve") @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java new file mode 100644 index 000000000..8e57b4b66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Original business commands invalidate the manifest atomically with their business write. */ +@Component +public class AppointmentWorkChanges { + private final AppointmentWorkMapper mapper; + public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; } + @Transactional(propagation=Propagation.MANDATORY) + public void requirementsChanged() { + if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java new file mode 100644 index 000000000..4e38652e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java @@ -0,0 +1,161 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkCommandService { + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway gateway; + private final AppointmentWorkSyncService sync; + private final AppointmentWorkQueryService queries; + public AppointmentWorkCommandService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway,AppointmentWorkSyncService sync,AppointmentWorkQueryService queries) { + this.store=store; this.gateway=gateway; this.sync=sync; this.queries=queries; + } + @Transactional + public CommandResult execute(String key,CommandRequest request,Actor actor) { + if(request==null || request.command()==null || request.expectedVersion()<0 || request.idempotencyKey()==null || request.idempotencyKey().isBlank() || request.idempotencyKey().length()>64 || request.payload()==null || !request.payload().isObject()) throw bad("工作命令参数无效"); + String permission=switch(request.command()) { case SAVE_FOLLOWUP,RESOLVE_REVIEW->FOLLOWUP; default->MANAGE; }; + if(!actor.has(LIST) || !actor.has(permission)) throw forbidden("缺少工作操作权限"); + store.lock(); + var state=store.get(key); + var hash=digest(actor.id()+"\n"+request.command()+"\n"+request.expectedVersion()+"\n"+canonical(request.payload())); + var replay=store.replay(key,request.idempotencyKey()); + if(replay!=null) { + if(!Objects.equals(actor.id(),replay.getOperatorId()) || !Objects.equals(hash,replay.getRequestHash())) throw conflict("同一请求编号的操作者或内容不一致"); + if(state.getReadPermissions().isEmpty() || !state.getReadPermissions().stream().allMatch(actor::has)) throw forbidden("关联来源读取权限已失效"); + return store.read(replay.getResponseJson(),CommandResult.class); + } + var current=gateway.facts(state.getType()==Type.ROUTE?List.of():List.of(state)); var fact=current.get(key); + if(!canRead(state,fact,actor)) throw forbidden("无权读取关联业务来源"); + state.setReadPermissions(state.getType()==Type.ROUTE?List.of(ORDER_READ):List.copyOf(fact.readPermissions())); + if(state.getVersion()!=request.expectedVersion()) throw conflict("事项已更新,请刷新后保留输入重新提交"); + if(state.isRetired() || completed(fact)) throw conflict("该事项已完成或已替代,只能查看历史"); + var payload=request.payload(); var affected=new LinkedHashSet(); String summary; + switch(request.command()) { + case CONFIRM_ROUTE -> { + fields(payload,Set.of("flow","reason"),Set.of("flow")); + if(state.getType()!=Type.ROUTE) throw bad("只能在路线需求确认运输方式"); + Flow flow; try { flow=Flow.valueOf(text(payload,"flow",true,16)); } catch(IllegalArgumentException e) { throw bad("运输路线无效"); } + if(flow==Flow.UNKNOWN) throw bad("请选择中转或直送"); + var source=gateway.container(state.getContainerBookingId()); AppointmentWorkSyncService.validateSource(source); + if(!Objects.equals(state.getSourceVersion(),source.sourceVersion())) throw conflict("柜次来源版本变化,请先同步核对"); + if(source.establishedFlow()!=Flow.UNKNOWN && source.establishedFlow()!=flow) throw conflict("选择路线与原业务既定路线不一致"); + var existing=store.container(state.getContainerBookingId()); + if(existing.stream().anyMatch(s->s.getType()!=Type.ROUTE)) throw conflict("该柜已生成路线工作,不能切换抹去已有安排"); + affected.addAll(sync.generate(source,flow,existing,actor)); state.setFlow(flow); state.setRetired(true); state.setRetirementReason("ROUTE_CONFIRMED"); state.setReplacementKeys(List.copyOf(affected)); + summary="确认"+(flow==Flow.TRANSFER?"海外仓中转":"整柜直送")+";"+Objects.toString(text(payload,"reason",false,1000),""); + } + case SAVE_FOLLOWUP -> { + fields(payload,Set.of("ownerId","followupDate","note"),Set.of("ownerId","followupDate","note")); + state.setOwner(gateway.owner(text(payload,"ownerId",true,64),actor)); + state.setFollowupDate(date(payload.get("followupDate"))); + summary=text(payload,"note",true,1000); + // This command never clears pending acceptance, review, or original work requirements. + } + case RESOLVE_REVIEW -> { + fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note")); + int version=integer(payload,"reviewVersion"); + if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对"); + if(changed(state,fact)) throw conflict("原业务已有更新,须先同步最新影响"); + if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排"); + summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null); + } + case SAVE_REQUIREMENT -> { + fields(payload,Set.of("target","bookingRequired","receivingRequirements","reason"),Set.of("target","bookingRequired","receivingRequirements","reason")); + if(!requirementOwned(state,fact)) throw conflict("该计划由原业务维护,请从原安排入口处理"); + if(!payload.get("bookingRequired").isBoolean()) throw bad("预约要求须为布尔值"); + boolean bookingRequired=payload.get("bookingRequired").asBoolean(); + String requirements=text(payload,"receivingRequirements",false,1000),reason=text(payload,"reason",true,1000); + if(!bookingRequired && (state.getType()!=Type.DELIVERY || !"CUSTOMER".equals(state.getDestinationKind()) || requirements==null)) throw bad("仅客户仓可凭明确收货要求登记无需预约"); + Slot target=slot(payload.get("target")); validateSlot(target); + String authorityZone=fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone(); + if(target!=null && !Objects.equals(authorityZone,target.timezone())) throw conflict("作业地点时区尚未核实或已变化"); + boolean hadPlan=arranged(state,fact); + boolean change=!Objects.equals(target,state.getTarget()) || state.isBookingRequired()!=bookingRequired || !Objects.equals(requirements,state.getReceivingRequirements()); + state.setTarget(target); state.setBookingRequired(bookingRequired); state.setReceivingRequirements(requirements); state.setRequirementReason(reason); + if(hadPlan && change) affected.addAll(markDownstream(state,reason,actor)); + summary=reason; + } + case HANDOFF_RETURN -> { + fields(payload,Set.of("recipient","reason"),Set.of("recipient","reason")); requireReturn(state); + var recipient=responsibility(payload.get("recipient"),actor); + String reason=text(payload,"reason",true,1000); + var previous=state.getResponsibility(); + if(previous==null && fact!=null) previous=fact.responsibility(); + if(previous==null) previous=new Responsibility(ResponsibilityKind.EXTERNAL,null,"原责任方待核实"); + int version=state.getReturnHandoff()==null?1:state.getReturnHandoff().version()+1; + state.setReturnHandoff(new ReturnHandoff(version,previous,recipient,HandoffStatus.PENDING_ACCEPTANCE,reason)); + if(state.getOwner()==null) state.setOwner(new Owner(actor.id(),actor.name())); + var localZone=queries.current(state,fact,actor).timezone(); + state.setFollowupDate(LocalDate.now(localZone==null?REMINDER_ZONE:zone(localZone))); + summary="还空责任转交:"+previous.name()+" → "+recipient.name()+";待接手;"+reason; + } + case ACCEPT_RETURN -> { + fields(payload,Set.of("handoffVersion","note"),Set.of("handoffVersion","note")); requireReturn(state); + var handoff=state.getReturnHandoff(); + if(handoff==null || handoff.status()!=HandoffStatus.PENDING_ACCEPTANCE || handoff.version()!=integer(payload,"handoffVersion")) throw conflict("还空转交版本变化或已经接手"); + if(handoff.to().kind()==ResponsibilityKind.USER && !actor.id().equals(handoff.to().id())) throw forbidden("仅当前指定接手人可确认接手"); + String note=text(payload,"note",handoff.to().kind()!=ResponsibilityKind.USER,1000); + state.setResponsibility(handoff.to()); state.setReturnHandoff(new ReturnHandoff(handoff.version(),handoff.from(),handoff.to(),HandoffStatus.ACCEPTED,handoff.reason())); + if(handoff.to().kind()==ResponsibilityKind.USER) state.setOwner(new Owner(actor.id(),actor.name())); + summary="已确认接手:"+handoff.to().name()+";"+Objects.toString(note,"本人确认"); + } + default -> throw bad("不支持的工作命令"); + } + store.save(state); affected.add(key); + var result=new CommandResult(queries.current(state,fact,actor),List.copyOf(affected)); + store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result); + return result; + } + private List markDownstream(AppointmentWorkState source,String reason,Actor actor) { + var states=store.all(); var keys=new LinkedHashSet(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey()); + var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList(); + for(var state:candidates) { var fact=facts.get(state.getWorkItemKey()); if(!completed(fact) && arranged(state,fact)) { + state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason(reason); store.save(state); + store.history(state,actor,"RELATED_CHANGE",reason,null,null,null); affected.add(state.getWorkItemKey()); + } } + return affected; + } + private Responsibility responsibility(JsonNode node,Actor actor) { + fields(node,Set.of("kind","id","name"),Set.of("kind","id","name")); + ResponsibilityKind kind; try { kind=ResponsibilityKind.valueOf(text(node,"kind",true,16)); } catch(IllegalArgumentException e) { throw bad("责任方类型无效"); } + var id=text(node,"id",false,64); var name=text(node,"name",true,200); + if(kind==ResponsibilityKind.USER) { var owner=gateway.owner(id,actor); return new Responsibility(kind,owner.id(),owner.name()); } + if(kind==ResponsibilityKind.SUPPLIER) { if(!actor.has("platform:supplier:list")) throw forbidden("缺少供应商读取权限"); return gateway.supplier(id); } + if(id!=null) throw bad("外部责任方不接受未经核验的内部标识"); + return new Responsibility(kind,null,name); + } + private static void requireReturn(AppointmentWorkState state) { if(state.getType()!=Type.EMPTY_RETURN) throw bad("仅还空事项可交接责任"); } + static void fields(JsonNode node,Set allowed,Set required) { + if(node==null || !node.isObject()) throw bad("命令payload须为对象"); + var names=new HashSet(); node.fieldNames().forEachRemaining(names::add); + if(!allowed.containsAll(names) || !names.containsAll(required)) throw bad("命令payload包含不支持的字段或缺少必需字段"); + } + static String text(JsonNode node,String field,boolean required,int max) { + var value=node.get(field); + if(value==null || value.isNull()) { if(required) throw bad("请填写"+field); return null; } + if(!value.isTextual() || value.textValue().length()>max) throw bad(field+"格式无效"); + var result=value.textValue().trim(); if(required && result.isEmpty()) throw bad("请填写"+field); return result.isEmpty()?null:result; + } + private int integer(JsonNode node,String name) { var value=node.get(name); if(value==null || !value.isIntegralNumber() || !value.canConvertToInt() || value.intValue()<0) throw bad(name+"必须为有效版本"); return value.intValue(); } + private LocalDate date(JsonNode value) { if(value==null || value.isNull()) return null; try { if(!value.isTextual()) throw bad("日期格式无效"); return LocalDate.parse(value.textValue()); } catch(java.time.DateTimeException e) { throw bad("日期格式无效"); } } + private Slot slot(JsonNode node) { + if(node==null || node.isNull()) return null; + fields(node,Set.of("date","startTime","endDate","endTime","timezone"),Set.of("date","startTime","endDate","endTime","timezone")); + try { return new Slot(date(node.get("date")),time(node.get("startTime")),date(node.get("endDate")),time(node.get("endTime")),text(node,"timezone",true,64)); } + catch(java.time.DateTimeException e) { throw bad("目标日期或时段格式无效"); } + } + private LocalTime time(JsonNode value) { if(value.isNull()) return null; if(!value.isTextual() || !value.textValue().matches("\\d{2}:\\d{2}")) throw bad("时段须为HH:mm"); return LocalTime.parse(value.textValue()); } + static String canonical(JsonNode node) { + if(node.isObject()) { var parts=new TreeMap(); node.fields().forEachRemaining(e->parts.put(e.getKey(),canonical(e.getValue()))); return parts.toString(); } + if(node.isArray()) { var parts=new ArrayList(); node.forEach(v->parts.add(canonical(v))); return parts.toString(); } + return node.toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java new file mode 100644 index 000000000..0cbe0c8a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Audit baseline for semantic impacts. Written only by original business transactions, never by reads. */ +@Component +public class AppointmentWorkCommitmentService { + public record Commitment(Slot target,Slot confirmed,String assignment,String cargoToken,boolean cancelled) { } + private final AppointmentWorkMapper mapper; + private final ObjectMapper json; + public AppointmentWorkCommitmentService(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + @Transactional(propagation=Propagation.MANDATORY) + public void record(String key,Commitment current) { + var row=mapper.lockCommitment(key); + try { + if(row==null) mapper.insertCommitment(key,json.writeValueAsString(current)); + else { + var previous=json.readValue(row.getStateJson(),Commitment.class); + mapper.updateCommitment(key,json.writeValueAsString(current),row.getImpactVersion()+(impacts(previous,current)?1:0)); + } + } catch(com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException("无法保存原业务承诺影响依据",error); } + } + public String impactToken(String key) { + var row=mapper.commitment(key); + return key+":"+(row==null?0:row.getImpactVersion()); + } + public static boolean impacts(Commitment old,Commitment now) { + if(old.cancelled()!=now.cancelled()) return true; + boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date()); + if(old.target()!=null && !Objects.equals(old.target(),now.target()) && !(firstSameDayConfirmation && now.target()==null)) return true; + if(old.confirmed()!=null && !Objects.equals(old.confirmed(),now.confirmed())) return true; + if(old.confirmed()==null && now.confirmed()!=null && old.target()!=null && !old.target().date().equals(now.confirmed().date())) return true; + if(old.assignment()!=null && !Objects.equals(old.assignment(),now.assignment())) return true; + return old.cargoToken()!=null && !Objects.equals(old.cargoToken(),now.cargoToken()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java new file mode 100644 index 000000000..021d5591a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.Set; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-work") +@PreAuthorize("hasAuthority('platform:appointment:list')") +public class AppointmentWorkController { + private final AppointmentWorkQueryService queries; + private final AppointmentWorkCommandService commands; + public AppointmentWorkController(AppointmentWorkQueryService queries,AppointmentWorkCommandService commands) { this.queries=queries; this.commands=commands; } + @GetMapping("/calendar") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set types) { + return ApiResponse.success(queries.calendar(from,to,flow,types,actor(user))); + } + @GetMapping("/summary") + public ApiResponse summary(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(queries.summary(actor(user))); } + @GetMapping("/items") + public ApiResponse> items(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue="ALL") View view,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size, + @RequestParam(required=false) LocalDate date,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set types, + @RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) { + return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user))); + } + @GetMapping("/items/{workItemKey}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) { + return ApiResponse.success(queries.detail(workItemKey,actor(user))); + } + @PostMapping("/items/{workItemKey}/commands") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey,@Valid @RequestBody CommandRequest request) { + return ApiResponse.success(commands.execute(workItemKey,request,actor(user))); + } + static Actor actor(AuthenticatedUser user) { + if(user==null || user.userId()==null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()) || !user.permissions().contains(LIST)) throw forbidden("仅平台有权访问工作事项"); + return new Actor(user.userId().toString(),user.displayName()==null?user.username():user.displayName(),user.permissions()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java new file mode 100644 index 000000000..a43df5748 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Direct transport uses terminal carrier arrangements and real destination unloading, never warehouse stock. */ +@Component +public class AppointmentWorkDirectTransportProvider implements BusinessProvider { + private final AppointmentWorkFirstlegClient firstleg; + private final String token; + public AppointmentWorkDirectTransportProvider(AppointmentWorkFirstlegClient firstleg,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.token=token; } + public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.DIRECT; } + public List read(List requests) { + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var input=new ArrayList(); + for(var r:requests) { input.add(request(r,Type.PICKUP,"/carrier")); input.add(request(r,Type.DELIVERY,"/delivery")); } + var facts=new HashMap(); + for(int start=0;start{ + var carrier=facts.get(r.workItemKey()+"/carrier"); var delivery=facts.get(r.workItemKey()+"/delivery"); + var original=carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.action():carrier.action(); + var action=original==null?null:new Action(original.code(),original.label(),r.workItemKey(),original.target()); + var read=new LinkedHashSet<>(carrier.readPermissions());read.addAll(delivery.readPermissions()); + var missing=new LinkedHashSet<>(carrier.missingFields());missing.addAll(delivery.missingFields()); + var refs=new LinkedHashSet<>(carrier.references());refs.addAll(delivery.references()); + boolean done=delivery.executionStatus()==ExecutionStatus.COMPLETED; + return new Fact(r.workItemKey(),digest(carrier.fingerprint()+":"+delivery.fingerprint()),carrier.impactToken(),carrier.planExists(),carrier.planStatus(), + done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING, + carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null, + List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action, + done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility()); + }).toList(); + } + private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java new file mode 100644 index 000000000..a8c8fd657 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.appointmentwork; + +import jakarta.validation.constraints.*; +import java.util.List; + +/** Real dispatch allocation boundary, implemented by the original appointment domain. */ +public interface AppointmentWorkDispatchPort { + record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId, + @NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { } + record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { } + void bind(String customerId,long dispatchId,List items,List allocations); + void requireReplay(String customerId,long dispatchId,List allocations); + void requireDeparture(String dispatchNo); + void removeItem(long dispatchId,long itemId); + void requireTripBinding(String dispatchNo,long tripId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java new file mode 100644 index 000000000..fe79dedee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { + private final AppointmentWorkFirstlegClient firstleg; + private final List providers; + private final ObjectProvider directory; + private final ObjectProvider requirements; + private final ObjectProvider suppliers; + private final String token; + public AppointmentWorkFactAdapter(AppointmentWorkFirstlegClient firstleg, ObjectProvider providers, + ObjectProvider directory, ObjectProvider requirements, ObjectProvider suppliers, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.firstleg=firstleg; this.providers=providers.orderedStream().toList(); this.directory=directory; this.requirements=requirements; this.suppliers=suppliers; this.token=token; + } + public SourcePage discover(String after, int size) { + requireToken(); + try { var response=firstleg.discover(token,after,size); + if(response==null || !response.successful() || response.data()==null) throw unavailable("已开船来源读取失败"); + var page=response.data(); + if(page.scopeVersion()==null || page.scopeVersion().isBlank() || page.containers()==null || page.containers().size()>size || !page.complete() && (page.nextCursor()==null || page.containers().isEmpty() || Objects.equals(after,page.nextCursor()))) + throw unavailable("已开船来源分页不完整"); + return page; + } catch(RuntimeException error) { throw unavailable("已开船来源不可用,不能计算完整工作范围",error); } + } + public ContainerSource container(String id) { + requireToken(); + try { var response=firstleg.container(token,id); + if(response==null || !response.successful() || response.data()==null || !id.equals(response.data().containerBookingId())) throw unavailable("柜次事实缺失"); + return response.data(); + } catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); } + } + public Map facts(List states) { + var result=new LinkedHashMap(); + var remote=new ArrayList(); + var groups=new LinkedHashMap>(); + for(var state:states) { + if(state.getType()==Type.ROUTE) continue; + var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),state.getContainerBookingId(),state.getRequirementKey(),state.getBatchKey(),state.getTripKey(),state.getBatchKeys(),state.getContainers(),state.getSources()); + if(state.getType()==Type.PICKUP || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && state.getFlow()==Flow.DIRECT) remote.add(request); + else { + var matches=providers.stream().filter(p->p.supports(state.getType(),state.getFlow())).toList(); + if(matches.size()!=1) throw unavailable("原业务事实适配尚未就绪:"+state.getType()); + groups.computeIfAbsent(matches.get(0),ignored->new ArrayList<>()).add(request); + } + } + for(int start=0; start{ + for(int start=0; start requirements(ContainerSource source,Flow effectiveFlow) { + var provider=requirements.getIfAvailable(); + if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); + var result=provider.requirements(source,effectiveFlow); + if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); + return List.copyOf(result); + } + static void append(Map target,List requests,List facts) { + if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整"); + var expected=new HashSet<>(requests.stream().map(FactRequest::workItemKey).toList()); + for(var fact:facts) { + if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null + || fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null) + throw unavailable("工作事实缺少身份、版本或资格"); + if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间"); + if(fact.actual()!=null) validateActual(fact.actual()); + if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突"); + validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed()); + if(fact.timezone()!=null) zone(fact.timezone()); + if(fact.actual()!=null && fact.actual().timezone()!=null) zone(fact.actual().timezone()); + if(fact.action()!=null && (fact.action().target()==null || fact.action().target() instanceof WorkTarget || fact.actionPermissions().isEmpty())) throw unavailable("原业务动作缺少独立权限或越过工作命令边界"); + if(target.putIfAbsent(fact.workItemKey(),fact)!=null) throw unavailable("工作事实重复"); + } + } + public Owner owner(String id,Actor actor) { + if(id==null || id.isBlank()) throw bad("请选择负责人"); + var resolver=directory.getIfAvailable(); + if(resolver==null) throw unavailable("负责人目录尚未接入,不能确认其他接手人"); + var owner=resolver.activePlatformOwner(id); + if(owner==null || !id.equals(owner.id())) throw bad("接手人不是有效平台操作人员"); + return owner; + } + public Responsibility supplier(String id) { + if(id==null || id.isBlank()) throw bad("请选择供应商责任方"); + var provider=suppliers.getIfAvailable(); + if(provider==null) throw unavailable("供应商权威目录适配尚未接入"); + var result=provider.activeSupplier(id); + if(result==null || result.kind()!=ResponsibilityKind.SUPPLIER || !id.equals(result.id()) || result.name()==null || result.name().isBlank()) throw bad("供应商责任方无效"); + return result; + } + private void requireToken() { if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java new file mode 100644 index 000000000..19ae2b33c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.appointmentwork; + +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Authority ports. A missing entity must be explicitly represented; missing responses are failures. */ +public interface AppointmentWorkFactGateway { + record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind, + String originName, String destinationName, String timezone, List references, + List replacesRequirementKeys, List sources) { } + record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, java.time.LocalDateTime departedLocalAt, java.time.LocalDateTime sailedEvidenceLocalAt, + String sourceVersion, Flow establishedFlow, String timezone, List requirements) { } + record SourcePage(List containers, String nextCursor, boolean complete, String scopeVersion) { } + record FactRequest(String workItemKey, Type type, Flow flow, String containerBookingId, + String requirementKey, String batchKey, String tripKey, List batchKeys, List containers, + List sources) { } + /** impactToken changes only for real changes to an existing commitment, never first arrangement/assignment. */ + record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, PlanStatus planStatus, + ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone, + String businessNo, String tripKey, List readPermissions, List actionPermissions, + Action action, List blockReasons, List missingFields, List references, + Responsibility responsibility) { + public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; } + } + SourcePage discover(String after, int size); + ContainerSource container(String containerBookingId); + /** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */ + List requirements(ContainerSource source,Flow effectiveFlow); + Map facts(List states); + Owner owner(String id, Actor actor); + Responsibility supplier(String id); + + /** Implemented in original Lastleg business domains; they may depend on this port, never vice versa. */ + interface BusinessProvider { + boolean supports(Type type, Flow flow); + List read(List requests); + } + /** IAM-backed active platform operators; display names supplied by callers are never trusted. */ + interface OwnerDirectory { Owner activePlatformOwner(String id); } + interface SupplierDirectory { Responsibility activeSupplier(String id); } + interface RequirementProvider { List requirements(ContainerSource source,Flow effectiveFlow); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java new file mode 100644 index 000000000..4051cf1f5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +/** Provider contract pending original-domain integration; HTTP errors are never treated as empty facts. */ +@FeignClient(name="firstleg-service", contextId="appointmentWorkFirstlegClient", + url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface AppointmentWorkFirstlegClient { + @GetMapping("/internal/firstleg/appointment-work/containers") + ApiResponse discover(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam(value="after", required=false) String after, @RequestParam("size") int size); + @GetMapping("/internal/firstleg/appointment-work/containers/{id}") + ApiResponse container(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("id") String id); + @PostMapping("/internal/firstleg/appointment-work/facts") + ApiResponse> facts(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody List requests); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java new file mode 100644 index 000000000..cbdda7cc5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Internal orchestration only, not exposed through the public gateway. */ +@RestController +@RequestMapping("/internal/lastleg/appointment-work") +public class AppointmentWorkInternalController { + private final InternalServiceTokenGuard guard; + private final AppointmentWorkSyncService sync; + private final AppointmentWorkRouteService routes; + public AppointmentWorkInternalController(InternalServiceTokenGuard guard,AppointmentWorkSyncService sync,AppointmentWorkRouteService routes) { this.guard=guard; this.sync=sync; this.routes=routes; } + @GetMapping("/containers/{id}/route") + public ApiResponse route(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable String id) { + guard.verify(token); return ApiResponse.success(routes.route(id)); + } + @PostMapping("/synchronize") + public ApiResponse synchronize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody SyncRequest request) { + guard.verify(token); return ApiResponse.success(sync.synchronize(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java new file mode 100644 index 000000000..bf802c8f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.appointmentwork; + +import lombok.Data; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentWorkMapper { + @Data class Row { + private String workItemKey; + private String containerBookingId; + private int version; + private boolean retired; + private String stateJson; + } + @Data class Guard { + private int id; + private String scanCursor; + private boolean initialized; + private String scopeVersion; + private String scanScopeVersion; + private LocalDateTime completedAt; + private long requirementVersion; + private long synchronizedRequirementVersion; + private long scanRequirementVersion; + } + @Data class History { + private String id; + private String workItemKey; + private String operatorId; + private String operatorName; + private String action; + private String summary; + private String commandKey; + private String requestHash; + private String responseJson; + private LocalDateTime occurredAt; + } + @Data class CommitmentRow { + private String entityKey; + private String stateJson; + private long impactVersion; + } + @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key); + @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key); + @Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)") + int insertCommitment(@Param("key") String key,@Param("json") String json); + @Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}") + int updateCommitment(@Param("key") String key,@Param("json") String json,@Param("version") long version); + @Select("SELECT * FROM ll_appointment_work_guard WHERE id=1 FOR UPDATE") Guard lockGuard(); + @Select("SELECT * FROM ll_appointment_work_guard WHERE id=1") Guard guard(); + @Update("UPDATE ll_appointment_work_guard SET scan_cursor=#{scanCursor},initialized=#{initialized},completed_at=#{completedAt},scope_version=#{scopeVersion},scan_scope_version=#{scanScopeVersion},synchronized_requirement_version=#{synchronizedRequirementVersion},scan_requirement_version=#{scanRequirementVersion} WHERE id=1") int saveGuard(Guard row); + @Update("UPDATE ll_appointment_work_guard SET requirement_version=requirement_version+1 WHERE id=1") int invalidateRequirements(); + @Select("SELECT * FROM ll_appointment_work WHERE work_item_key=#{key}") Row get(String key); + @Select("SELECT * FROM ll_appointment_work ORDER BY work_item_key LIMIT #{limit}") List all(int limit); + @Select("SELECT * FROM ll_appointment_work WHERE container_booking_id=#{id} ORDER BY work_item_key") List container(String id); + @Insert("INSERT INTO ll_appointment_work(work_item_key,container_booking_id,version,retired,state_json) VALUES(#{workItemKey},#{containerBookingId},#{version},#{retired},#{stateJson})") int insert(Row row); + @Update("UPDATE ll_appointment_work SET version=#{version},retired=#{retired},state_json=#{stateJson} WHERE work_item_key=#{workItemKey} AND version=#{version}-1") int update(Row row); + @Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND command_key=#{commandKey}") + History command(@Param("key") String key,@Param("commandKey") String commandKey); + @Insert(""" + INSERT INTO ll_appointment_work_history(id,work_item_key,operator_id,operator_name,action,summary,command_key,request_hash,response_json,occurred_at) + VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt}) + """) int insertHistory(History row); + @Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List selectHistory(@Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java new file mode 100644 index 000000000..4fb6028ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; +import java.time.*; +import java.util.*; + +/** Public v1 contracts. No editable cargo quantities or execution commands live here. */ +public final class AppointmentWorkModels { + private AppointmentWorkModels() { } + public enum Type { ROUTE, PICKUP, WAREHOUSE_RECEIPT, DELIVERY, TRANSPORT, EMPTY_RETURN } + public enum Flow { TRANSFER, DIRECT, UNKNOWN } + public enum PlanStatus { UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED } + public enum ExecutionStatus { PENDING, IN_PROGRESS, COMPLETED } + public enum DateBasis { ACTUAL, CONFIRMED, TARGET, NONE } + public enum View { ALL, FOLLOW_TODAY, OVERDUE, UNDATED, REVIEW } + public enum TargetKind { WORK_COMMAND, CARGO_VERIFICATION, FBA_APPOINTMENT, FIRSTLEG_CONTAINER, FLEET_TRIP, WAREHOUSE_RECEIPT, READ_ONLY } + public enum Command { CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN } + public enum Bucket { PENDING, CONFIRMED, NO_BOOKING_REQUIRED, REVIEW, COMPLETED } + public enum ActionCode { VIEW_DETAIL, SAVE_PLAN, REQUEST_BOOKING, RECORD_CONFIRMATION, CHANGE_BOOKING, CANCEL_BOOKING, VERIFY_CARGO, EDIT_BATCH, ARRANGE_TRANSPORT, RECORD_EXECUTION, CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN, VIEW_HISTORY } + public record Slot(LocalDate date, @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime startTime, LocalDate endDate, + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime endTime, String timezone) { } + public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { } + public record Owner(String id, String name) { } + public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { } + public record SourceReference(String customerId, String sourceKey, String destinationKey) { } + @JsonTypeInfo(use=JsonTypeInfo.Id.NAME, property="kind") + @JsonSubTypes({@JsonSubTypes.Type(value=WorkTarget.class,name="WORK_COMMAND"), @JsonSubTypes.Type(value=CargoTarget.class,name="CARGO_VERIFICATION"), + @JsonSubTypes.Type(value=AppointmentTarget.class,name="FBA_APPOINTMENT"), @JsonSubTypes.Type(value=ContainerTarget.class,name="FIRSTLEG_CONTAINER"), + @JsonSubTypes.Type(value=TripTarget.class,name="FLEET_TRIP"), @JsonSubTypes.Type(value=ReceiptTarget.class,name="WAREHOUSE_RECEIPT"), @JsonSubTypes.Type(value=ReadTarget.class,name="READ_ONLY")}) + public sealed interface ActionTarget permits WorkTarget,CargoTarget,AppointmentTarget,ContainerTarget,TripTarget,ReceiptTarget,ReadTarget { } + public record WorkTarget(Command command) implements ActionTarget { } + public record CargoTarget(SourceReference source) implements ActionTarget { } + public record AppointmentTarget(String appointmentId,String batchKey,List sources) implements ActionTarget { } + public record ContainerTarget(String containerBookingId) implements ActionTarget { } + public record TripTarget(String tripId,List batchKeys) implements ActionTarget { } + public record ReceiptTarget(String inboundPlanId,String inboundPlanNo,String customerId,String containerBookingId) implements ActionTarget { } + public record ReadTarget(List references) implements ActionTarget { } + public record Action(ActionCode code, String label, String targetWorkItemKey, ActionTarget target) { } + public record WorkItem(String workItemKey, int version, Type type, Flow flow, String title, + String businessNo, List containers, String requirementKey, + String batchKey, String batchNo, String originName, String destinationName, + PlanStatus planStatus, ExecutionStatus executionStatus, boolean reviewRequired, + Slot target, Slot confirmed, Actual actual, LocalDate calendarDate, DateBasis dateBasis, + String timezone, Owner owner, LocalDate followupDate, List overdueReasons, + List allowedActions, Action nextAction, List blockReasons, List missingFields) { } + public record BusinessReference(String kind, String key, String businessNo, String label) { } + public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { } + public record WorkDetail(WorkItem item, List relatedItems, + List references, List history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { } + public record Review(int version,List reasons) { } + public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { } + public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL } + public record Responsibility(ResponsibilityKind kind,String id,String name) { } + public enum HandoffStatus { PENDING_ACCEPTANCE, ACCEPTED } + public record ReturnHandoff(int version,Responsibility from,Responsibility to,HandoffStatus status,String reason) { } + public record Summary(long all, long followToday, long overdue, long undated, long review) { } + public record BucketCounts(long pending, long confirmed, long noBookingRequired, long review, long completed) { } + public record DayAggregate(LocalDate date, long total, Map typeCounts, BucketCounts counts) { } + public record CalendarResult(LocalDate from, LocalDate to, long total, BucketCounts counts, List days) { } + public record CommandRequest(@NotNull Command command, @Min(0) int expectedVersion, + @NotBlank @Size(max=64) String idempotencyKey, @NotNull JsonNode payload) { } + public record CommandResult(WorkItem item, List affectedWorkItemKeys) { } + public record SyncRequest(@Size(max=128) String afterContainerBookingId, @Min(1) @Max(100) int size) { } + public record SyncResult(int sources, int created, String nextCursor, boolean complete) { } + public record Actor(String id, String name, Set permissions) { + public boolean has(String permission) { return permissions.contains(permission); } + } + public static final String LIST = "platform:appointment:list"; + public static final String FOLLOWUP = "platform:appointment:followup"; + public static final String MANAGE = "platform:appointment:manage-work"; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java new file mode 100644 index 000000000..3f58df68f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="iam-service",contextId="appointmentWorkOwnerClient",url="${baiyue.clients.iam.url:}") +public interface AppointmentWorkOwnerClient { + @GetMapping("/internal/iam/platform-owners/{authUserId}") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("authUserId") String id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java new file mode 100644 index 000000000..5b02e466b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkOwnerDirectory implements AppointmentWorkFactGateway.OwnerDirectory { + private final AppointmentWorkOwnerClient client; + private final String token; + public AppointmentWorkOwnerDirectory(AppointmentWorkOwnerClient client,@Value("${baiyue.security.internal-service-token:}") String token) { this.client=client; this.token=token; } + public AppointmentWorkModels.Owner activePlatformOwner(String id) { + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var response=client.active(token,id); + if(response==null || !response.successful() || response.data()==null) throw unavailable("平台人员权威目录读取失败"); + var owner=response.data(); + if(!id.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw unavailable("平台人员目录返回身份或姓名不完整"); + return owner; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java new file mode 100644 index 000000000..32d79846e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +public final class AppointmentWorkPolicy { + /** Explicit business fallback for reminder dates only; never used to fabricate actual/appointment time. */ + public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles"); + public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list"; + private AppointmentWorkPolicy() { } + public static String key(String containerId,Type type,String requirementKey) { + return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32); + } + public static String digest(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch(Exception e) { throw new IllegalStateException(e); } + } + public static void validateSlot(Slot slot) { + if(slot==null) return; + if(slot.date()==null || slot.timezone()==null || slot.timezone().isBlank()) throw bad("目标日期与地点时区必填"); + validateSourceSlot(slot); + var zone=zone(slot.timezone()); + for(int index=0; index<2; index++) { var time=index==0?slot.startTime():slot.endTime(); if(time==null) continue; + if(time.getSecond()!=0 || time.getNano()!=0) throw bad("时间精度须为分钟"); + if(zone.getRules().getValidOffsets(LocalDateTime.of(index==0?slot.date():slot.endDate(),time)).size()!=1) throw bad("当地时段存在夏令时跳变或歧义,请重新选择"); + } + } + /** Original local plans remain visible even when their source did not record a timezone. */ + public static void validateSourceSlot(Slot slot) { + if(slot==null) return; + if(slot.date()==null) throw unavailable("原计划缺少当地日期"); + if(slot.timezone()!=null) zone(slot.timezone()); + if((slot.endDate()==null)!=(slot.endTime()==null) || slot.startTime()==null && slot.endTime()!=null) throw bad("结束日期与时间须同时填写,并先明确开始时间"); + if(slot.endTime()!=null && !LocalDateTime.of(slot.endDate(),slot.endTime()).isAfter(LocalDateTime.of(slot.date(),slot.startTime()))) throw bad("完整结束时间必须晚于开始时间,不自动推断次日"); + } + public static void validateActual(Actual actual) { + if(actual.occurredAt()==null && actual.localOccurredAt()==null) throw unavailable("实际事实缺少发生时间"); + if(actual.timezone()!=null) { + var zone=zone(actual.timezone()); + if(actual.occurredAt()!=null && actual.localOccurredAt()!=null && !actual.occurredAt().atZone(zone).toLocalDateTime().equals(actual.localOccurredAt())) + throw unavailable("实际发生时刻与当地时间不一致"); + } + } + public static ZoneId zone(String value) { + if(value==null || !ZoneId.getAvailableZoneIds().contains(value)) throw bad("需要有效IANA地点时区"); + return ZoneId.of(value); + } + public static boolean pendingHandoff(AppointmentWorkState state) { + return state.getReturnHandoff()!=null && state.getReturnHandoff().status()==HandoffStatus.PENDING_ACCEPTANCE; + } + public static boolean canRead(AppointmentWorkState state,Fact fact,Actor actor) { + return actor.has(LIST) && (state.getType()==Type.ROUTE ? actor.has(ORDER_READ) : fact!=null && fact.readPermissions().stream().allMatch(actor::has)); + } + public static boolean arranged(AppointmentWorkState state,Fact fact) { + return state.getTarget()!=null || !state.isBookingRequired() || fact!=null && fact.arranged(); + } + public static boolean changed(AppointmentWorkState state,Fact fact) { + return fact!=null && state.getObservedFingerprint()!=null && !Objects.equals(state.getObservedFingerprint(),fact.fingerprint()); + } + public static boolean completed(Fact fact) { return fact!=null && fact.executionStatus()==ExecutionStatus.COMPLETED; } + public static WorkItem project(AppointmentWorkState state,Fact fact,Actor actor,Clock clock) { + boolean route=state.getType()==Type.ROUTE; + if(!route && fact==null) throw unavailable("缺少事项权威事实"); + var target=fact!=null && fact.planExists()?fact.target():state.getTarget(); + var confirmed=fact==null?null:fact.confirmed(); + var actual=fact==null?null:fact.actual(); + var execution=route?state.isRetired()?ExecutionStatus.COMPLETED:ExecutionStatus.PENDING:fact.executionStatus(); + var plan=!state.isBookingRequired()?PlanStatus.NOT_REQUIRED:fact!=null && fact.planExists()?fact.planStatus():target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED; + var basis=actual!=null?DateBasis.ACTUAL:confirmed!=null?DateBasis.CONFIRMED:target!=null?DateBasis.TARGET:DateBasis.NONE; + String timezone=actual!=null?actual.timezone():confirmed!=null?confirmed.timezone():target!=null?target.timezone():fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone(); + LocalDate date=null; + var blocks=new ArrayList(); var missing=new ArrayList(); + if(fact!=null) { blocks.addAll(fact.blockReasons()); missing.addAll(fact.missingFields()); } + if(actual!=null) { + validateActual(actual); + if(timezone!=null && actual.occurredAt()!=null) date=actual.occurredAt().atZone(zone(timezone)).toLocalDate(); + else if(actual.localOccurredAt()!=null) date=actual.localOccurredAt().toLocalDate(); + if(timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE"); + } + else if(confirmed!=null) { validateSourceSlot(confirmed); date=confirmed.date(); } + else if(target!=null) { validateSourceSlot(target); date=target.date(); } + if((confirmed!=null || target!=null) && timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE"); + var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone))); + if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实"); + if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配"); + var overdue=new ArrayList(); + if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) { + if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过"); + if(date!=null && date.isBefore(today)) overdue.add("作业日期已过,尚未完成"); + } + if(pendingHandoff(state) && execution!=ExecutionStatus.COMPLETED) blocks.add("还空接手尚未确认,继续跟进"); + if(changed(state,fact)) blocks.add("原业务事实已变化,须同步影响后再复核"); + var actions=new ArrayList(); + if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) { + if(actor.has(FOLLOWUP)) actions.add(work(state,Command.SAVE_FOLLOWUP,"记录回复与跟进")); + if(route && actor.has(MANAGE)) actions.add(work(state,Command.CONFIRM_ROUTE,"确认路线")); + if(state.isReviewRequired() && actor.has(FOLLOWUP) && !changed(state,fact)) actions.add(work(state,Command.RESOLVE_REVIEW,"复核安排")); + if(actor.has(MANAGE) && requirementOwned(state,fact)) actions.add(work(state,Command.SAVE_REQUIREMENT,"登记作业要求")); + if(state.getType()==Type.EMPTY_RETURN && actor.has(MANAGE)) { + actions.add(work(state,Command.HANDOFF_RETURN,"转交还空责任")); + if(pendingHandoff(state) && (state.getReturnHandoff().to().kind()!=ResponsibilityKind.USER || actor.id().equals(state.getReturnHandoff().to().id()))) actions.add(work(state,Command.ACCEPT_RETURN,"确认接手反馈")); + } + if(fact!=null && fact.action()!=null) { + boolean executionBlocked=fact.action().code()==ActionCode.RECORD_EXECUTION && (!fact.blockReasons().isEmpty() || state.getType()!=Type.EMPTY_RETURN && (state.isReviewRequired() || changed(state,fact))); + if(fact.actionPermissions().stream().allMatch(actor::has) && !executionBlocked) actions.add(fact.action()); + else if(!fact.actionPermissions().stream().allMatch(actor::has)) blocks.add("缺少原业务操作权限,可查看安排并联系有权限人员处理"); + } + } + var read=new Action(execution==ExecutionStatus.COMPLETED?ActionCode.VIEW_HISTORY:ActionCode.VIEW_DETAIL,"查看详情",state.getWorkItemKey(),new ReadTarget(state.getReferences())); + actions.add(read); + Action next=actions.stream().filter(a->a.code()==ActionCode.ACCEPT_RETURN).findFirst().orElse(null); + if(next==null && pendingHandoff(state)) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read); + if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.CONFIRM_ROUTE || a.code()==ActionCode.RESOLVE_REVIEW).findFirst().orElse(null); + if(next==null && fact!=null && fact.action()!=null && actions.contains(fact.action())) next=fact.action(); + if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_REQUIREMENT || a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read); + var names=Map.of(Type.ROUTE,"确认路线",Type.PICKUP,"提柜",Type.WAREHOUSE_RECEIPT,"接仓卸柜",Type.DELIVERY,"送仓",Type.TRANSPORT,"用车",Type.EMPTY_RETURN,"还空柜"); + var businessNo=fact==null?null:fact.businessNo(); + var label=businessNo!=null?businessNo:state.getBatchNo()!=null?state.getBatchNo():state.getContainerNo()!=null?state.getContainerNo():"—"; + return new WorkItem(state.getWorkItemKey(),state.getVersion(),state.getType(),state.getFlow(),label+" · "+names.get(state.getType()),businessNo, + state.getContainers().isEmpty()?List.of(new ContainerReference(state.getContainerBookingId(),null,state.getContainerNo())):List.copyOf(state.getContainers()),state.getRequirementKey(),state.getBatchKey(),state.getBatchNo(), + state.getOriginName(),state.getDestinationName(),plan,execution,state.isReviewRequired() && execution!=ExecutionStatus.COMPLETED, + target,confirmed,actual,date,basis,timezone,state.getOwner(),state.getFollowupDate(),List.copyOf(overdue),List.copyOf(actions),next,List.copyOf(blocks),List.copyOf(missing)); + } + public static boolean requirementOwned(AppointmentWorkState state,Fact fact) { + return (fact==null || !fact.planExists()) && (state.getType()==Type.WAREHOUSE_RECEIPT || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && "CUSTOMER".equals(state.getDestinationKind())); + } + public static Action work(AppointmentWorkState state,Command command,String label) { + return new Action(ActionCode.valueOf(command.name()),label,state.getWorkItemKey(),new WorkTarget(command)); + } + public static boolean followToday(WorkItem item,Clock clock) { + var today=LocalDate.now(clock.withZone(item.timezone()==null?REMINDER_ZONE:zone(item.timezone()))); + return item.followupDate()!=null && !item.followupDate().isAfter(today) || item.blockReasons().contains("还空接手尚未确认,继续跟进"); + } + public static Bucket bucket(WorkItem item) { + return item.executionStatus()==ExecutionStatus.COMPLETED?Bucket.COMPLETED:item.reviewRequired()?Bucket.REVIEW: + item.planStatus()==PlanStatus.CONFIRMED?Bucket.CONFIRMED:item.planStatus()==PlanStatus.NOT_REQUIRED?Bucket.NO_BOOKING_REQUIRED:Bucket.PENDING; + } + public static BucketCounts counts(List items) { + var counts=new EnumMap(Bucket.class); items.forEach(i->counts.merge(bucket(i),1L,Long::sum)); + return new BucketCounts(counts.getOrDefault(Bucket.PENDING,0L),counts.getOrDefault(Bucket.CONFIRMED,0L),counts.getOrDefault(Bucket.NO_BOOKING_REQUIRED,0L),counts.getOrDefault(Bucket.REVIEW,0L),counts.getOrDefault(Bucket.COMPLETED,0L)); + } + public static List effectiveKeys(Collection keys,Map states) { + var pending=new ArrayDeque<>(keys); var seen=new HashSet(); var effective=new LinkedHashSet(); + while(!pending.isEmpty()) { + var key=pending.remove(); if(!seen.add(key)) continue; + var state=states.get(key); + if(state!=null && state.isRetired() && !state.getReplacementKeys().isEmpty()) pending.addAll(state.getReplacementKeys()); + else effective.add(key); + } + return List.copyOf(effective); + } + public static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,text); } + public static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT,text); } + public static ResponseStatusException missing(String text) { return new ResponseStatusException(HttpStatus.NOT_FOUND,text); } + public static ResponseStatusException forbidden(String text) { return new ResponseStatusException(HttpStatus.FORBIDDEN,text); } + public static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text); } + public static ResponseStatusException unavailable(String text,Throwable cause) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text,cause); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java new file mode 100644 index 000000000..5623f1bd0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.PageResult; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +@Transactional(readOnly=true) +public class AppointmentWorkQueryService { + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway facts; + private final Clock clock; + @org.springframework.beans.factory.annotation.Autowired + public AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts) { this(store,facts,Clock.systemUTC()); } + AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; } + record Snapshot(List states,Map facts,List items) { } + Snapshot snapshot(Actor actor) { + String scopeVersion=store.requireInitialized(); + if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); + var all=store.all(); + var states=all.stream().filter(s->!s.isRetired()).toList(); + var current=facts.facts(states); + var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); + var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList(); + var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity())); + var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + return new Snapshot(all,current,items.stream().map(i->withNext(i,next(i,byKey,stateByKey,new HashSet<>()))).toList()); + } + private Action next(WorkItem item,Map items,Map states,Set visited) { + if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction(); + for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) { + var prior=items.get(key); + if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) { + var action=next(prior,items,states,visited); + if(action!=null) return new Action(action.code(),"先"+action.label().replaceFirst("^先",""),action.targetWorkItemKey(),action.target()); + } + } + return item.nextAction(); + } + public Summary summary(Actor actor) { + var items=snapshot(actor).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); + return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count()); + } + public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set types,Actor actor) { + if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天"); + if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看"); + var items=snapshot(actor).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) + .filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList(); + var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList())); + var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList(); + return new CalendarResult(from,to,items.size(),counts(items),days); + } + public PageResult items(View view,int page,int size,LocalDate date,Flow flow,Set types,String containerId,Bucket bucket,String keyword,Actor actor) { + if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效"); + var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT); + var items=snapshot(actor).items().stream() + .filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED) + .filter(i->date==null || date.equals(i.calendarDate())) + .filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type())) + .filter(i->containerId==null || i.containers().stream().anyMatch(c->containerId.equals(c.containerBookingId()))) + .filter(i->bucket==null || bucket==bucket(i)) + .filter(i->word.isEmpty() || searchable(i).contains(word)) + .filter(i->switch(view) { case ALL->true; case FOLLOW_TODAY->i.executionStatus()!=ExecutionStatus.COMPLETED && followToday(i,clock); case OVERDUE->!i.overdueReasons().isEmpty(); case UNDATED->i.calendarDate()==null; case REVIEW->i.reviewRequired(); }) + .sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList(); + long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size()); + return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size); + } + public WorkDetail detail(String key,Actor actor) { + var snapshot=snapshot(actor); var state=store.get(key); + var fact=snapshot.facts().get(key); + if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key); + if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源"); + var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在")); + var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys()); + var statesByKey=snapshot.states().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey)); + snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add); + var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references()); + return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key), + state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null, + requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff()); + } + WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); } + private String searchable(WorkItem i) { + return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT); + } + private WorkItem withNext(WorkItem i,Action next) { + return new WorkItem(i.workItemKey(),i.version(),i.type(),i.flow(),i.title(),i.businessNo(),i.containers(),i.requirementKey(),i.batchKey(),i.batchNo(),i.originName(),i.destinationName(),i.planStatus(),i.executionStatus(),i.reviewRequired(),i.target(),i.confirmed(),i.actual(),i.calendarDate(),i.dateBasis(),i.timezone(),i.owner(),i.followupDate(),i.overdueReasons(),i.allowedActions(),next,i.blockReasons(),i.missingFields()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java new file mode 100644 index 000000000..835e9fffc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +@Service +public class AppointmentWorkRouteService { + public record Route(Flow flow) { } + private final AppointmentWorkStore store; + public AppointmentWorkRouteService(AppointmentWorkStore store) { this.store=store; } + /** Local committed state only. No guard lock and no upstream HTTP calls. */ + @Transactional(readOnly=true) + public Route route(String container) { + var flows=store.container(container).stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getFlow).filter(f->f!=null && f!=Flow.UNKNOWN).distinct().toList(); + if(flows.size()>1) throw AppointmentWorkPolicy.conflict("柜次持久路线冲突,不能执行原业务写入"); + return new Route(flows.isEmpty()?Flow.UNKNOWN:flows.get(0)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java new file mode 100644 index 000000000..6c19a5364 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.appointmentwork; + +import lombok.Data; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +/** Work-owned state only. observedFingerprint is an audit baseline, never a second business plan. */ +@Data +public class AppointmentWorkState { + private String workItemKey; + private int version; + private Type type; + private Flow flow; + private String containerBookingId; + private String containerNo; + private String requirementKey; + private String batchKey; + private String batchNo; + private String tripKey; + private List batchKeys = new ArrayList<>(); + private List containers = new ArrayList<>(); + private String destinationKind; + private String originName; + private String destinationName; + private String timezone; + private String sourceVersion; + private Slot target; + private boolean bookingRequired = true; + private String receivingRequirements; + private String requirementReason; + private Owner owner; + private LocalDate followupDate; + private boolean reviewRequired; + private int reviewVersion; + private String reviewReason; + private String observedFingerprint; + private String observedImpactToken; + private boolean observedArranged; + private Responsibility responsibility; + private ReturnHandoff returnHandoff; + private boolean retired; + private String retirementReason; + private List replacementKeys = new ArrayList<>(); + private List prerequisites = new ArrayList<>(); + private List references = new ArrayList<>(); + private List sources = new ArrayList<>(); + private List readPermissions = new ArrayList<>(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java new file mode 100644 index 000000000..77c6f204d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkStore { + public static final int MAX_ITEMS=10000; + private final AppointmentWorkMapper mapper; + private final ObjectMapper json; + public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + public AppointmentWorkMapper.Guard lock() { + var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard; + } + public String requireInitialized() { + var guard=mapper.guard(); + if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项"); + if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单"); + return guard.getScopeVersion(); + } + public void checkpoint(AppointmentWorkMapper.Guard guard) { mapper.saveGuard(guard); } + public List all() { + var rows=mapper.all(MAX_ITEMS+1); + if(rows.size()>MAX_ITEMS) throw conflict("工作事项超过当前全量核对上限,不能返回截断统计"); + return rows.stream().map(this::decode).toList(); + } + public List container(String id) { return all().stream().filter(s->id.equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->id.equals(c.containerBookingId()))).toList(); } + public AppointmentWorkState get(String key) { + var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row); + } + public void insert(AppointmentWorkState state) { mapper.insert(row(state)); } + public void save(AppointmentWorkState state) { + state.setVersion(state.getVersion()+1); + if(mapper.update(row(state))!=1) throw conflict("事项版本已变化,请刷新后处理"); + } + public AppointmentWorkMapper.History replay(String key,String commandKey) { return mapper.command(key,commandKey); } + public void history(AppointmentWorkState state,Actor actor,String action,String summary,String commandKey,String hash,Object result) { + var row=new AppointmentWorkMapper.History(); row.setId(UUID.randomUUID().toString()); row.setWorkItemKey(state.getWorkItemKey()); + row.setOperatorId(actor.id()); row.setOperatorName(actor.name()); row.setAction(action); row.setSummary(summary); + row.setCommandKey(commandKey); row.setRequestHash(hash); row.setResponseJson(result==null?null:write(result)); + row.setOccurredAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); mapper.insertHistory(row); + } + public List history(String key) { + var rows=mapper.selectHistory(key); + if(rows.size()>1000) throw conflict("事项历史超过详情上限,需接入历史分页后查看"); + return rows.stream().map(r->new HistoryEntry(r.getOccurredAt().toInstant(ZoneOffset.UTC),r.getOperatorName(),r.getAction(),r.getSummary())).toList(); + } + public String write(Object value) { try { return json.writeValueAsString(value); } catch(Exception e) { throw new IllegalStateException("工作事项序列化失败",e); } } + public T read(String value,Class type) { try { return json.readValue(value,type); } catch(Exception e) { throw new IllegalStateException("工作事项记录读取失败",e); } } + private AppointmentWorkState decode(AppointmentWorkMapper.Row row) { return read(row.getStateJson(),AppointmentWorkState.class); } + private AppointmentWorkMapper.Row row(AppointmentWorkState state) { + var row=new AppointmentWorkMapper.Row(); row.setWorkItemKey(state.getWorkItemKey()); row.setContainerBookingId(state.getContainerBookingId()); + row.setVersion(state.getVersion()); row.setRetired(state.isRetired()); row.setStateJson(write(state)); return row; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java new file mode 100644 index 000000000..f1eed7aac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -0,0 +1,217 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkSyncService { + private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ)); + private final AppointmentWorkStore store; + private final AppointmentWorkFactGateway gateway; + public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; } + /** Called by trusted server orchestration, never by GET or by opening a particular container. */ + @Transactional + public SyncResult synchronize(SyncRequest request) { + if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); + var guard=store.lock(); + if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对"); + var page=gateway.discover(request.afterContainerBookingId(),request.size()); + if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); } + else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试"); + else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试"); + int created=0; var ids=new HashSet(); + for(var source:page.containers()) { + validateSource(source); + if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次"); + var existing=store.container(source.containerBookingId()); + if(existing.isEmpty()) { + if(source.establishedFlow()==Flow.UNKNOWN) { + var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++; + } else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size(); + } else { + var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null); + if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size(); + else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) { + if(source.establishedFlow()!=Flow.UNKNOWN) { + var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size(); + route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route); + } else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) { + route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route); + } + } + } + } + // An existing row is never deleted because an order left the active source page. + // Empty return and other unfinished work continue to read their original authority. + var all=new ArrayList<>(store.all()); + var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList()); + reconcileTransport(all,facts); + all=new ArrayList<>(store.all()); + facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList()); + observe(all,facts,SYSTEM); + guard.setScanCursor(page.complete()?null:page.nextCursor()); + if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); } + store.checkpoint(guard); + return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete()); + } + static void validateSource(ContainerSource source) { + if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null) + throw unavailable("已开船来源缺少身份或真实开船依据"); + if(source.timezone()!=null) zone(source.timezone()); + var seen=new HashSet(); var batches=new HashSet(); + for(var requirement:source.requirements()) { + if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null) + throw unavailable("送货需求缺少稳定身份或重复"); + if(requirement.batchKey()!=null && !batches.add(requirement.batchKey())) throw unavailable("同柜批次需求重复,必须由原域归并来源"); + if(requirement.timezone()!=null) zone(requirement.timezone()); + } + } + /** Caller holds work guard and validates the route against current original facts. */ + List generate(ContainerSource source,Flow flow,List existing,Actor actor) { + validateSource(source); + if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确"); + var requirements=gateway.requirements(source,flow); + source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements); + validateSource(source); + if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单"); + var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a)); + var created=new ArrayList(); + for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created); + for(var requirement:source.requirements()) { + add(source,Type.DELIVERY,requirement,flow,known,created); + add(source,Type.TRANSPORT,requirement,flow,known,created); + } + for(var state:created) { + state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name())); + state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone()))); + String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null); + if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup)); + if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey()))); + if(state.getType()==Type.TRANSPORT) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.DELIVERY,state.getRequirementKey(),state.getBatchKey()))); + if(state.getType()==Type.EMPTY_RETURN) { + String containerId=source.containerBookingId(); + state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList()); + } + store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null); + } + String generationContainerId=source.containerBookingId(); + var activeKeys=new HashSet(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); }); + for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) { + String containerId=source.containerBookingId(); + // A mutable original dispatch can remove this container's cargo while keeping other containers. + // This scan is a complete authority manifest for this container, including its remaining obligations. + var retained=old.getContainers().stream().filter(c->!containerId.equals(c.containerBookingId())).toList(); + if(old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:") && !retained.isEmpty()) { + old.setContainers(retained); + if(containerId.equals(old.getContainerBookingId())) { old.setContainerBookingId(retained.get(0).containerBookingId());old.setContainerNo(retained.get(0).containerNo()); } + var removedDependency=key(containerId,flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null); + old.setPrerequisites(old.getPrerequisites().stream().filter(k->!removedDependency.equals(k)).toList()); + store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue; + } + var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey())) + .map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList(); + // Removed dispatch items no longer exist in the original allocation table. Its full current + // container manifest restores the unallocated demand or names the newly assigned dispatch. + if(replacements.isEmpty() && old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:")) + replacements=requirements.stream().filter(r->"CUSTOMER".equals(r.destinationKind()) && r.sources().stream().anyMatch(old.getSources()::contains)).map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList(); + if(replacements.isEmpty()) throw conflict("原需求离开有效清单但没有明确替代关系,不能隐藏剩余工作"); + old.setRetired(true); old.setRetirementReason("BATCH_REPLACED"); old.setReplacementKeys(replacements); store.save(old); store.history(old,actor,"REPLACED","原预约域已提供完整替代需求",null,null,null); + } + return created.stream().map(AppointmentWorkState::getWorkItemKey).toList(); + } + private static String requirementKey(String container,Type type,String requirement,String batch) { return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,null); } + private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map known,List created) { + var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey()); + if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); } + else if(requirement!=null) { + if("BATCH_REPLACED".equals(old.getRetirementReason())) { + // The full authority manifest explicitly reopens a cancelled obligation/restored original appointment. + // Repartitioned batches never appear here: the provider excludes replacement-bearing originals. + old.setRetired(false); old.setRetirementReason(null); old.setReplacementKeys(List.of()); store.save(old); + } + if(old.getFlow()!=flow) throw conflict("同一批次不能混合中转与直送路线"); + var containers=new ArrayList<>(old.getContainers()); + boolean update=!Objects.equals(old.getBatchNo(),requirement.batchNo()) || !Objects.equals(old.getOriginName(),requirement.originName()) || !Objects.equals(old.getDestinationName(),requirement.destinationName()) + || !Objects.equals(old.getDestinationKind(),requirement.destinationKind()) || !Objects.equals(old.getReferences(),requirement.references()) || !Objects.equals(old.getSources(),requirement.sources()) || type==Type.DELIVERY && !Objects.equals(old.getTimezone(),requirement.timezone()); + if(containers.stream().noneMatch(c->c.containerBookingId().equals(source.containerBookingId()))) { + containers.addAll(state.getContainers()); old.setContainers(List.copyOf(containers)); + var dependencies=new LinkedHashSet<>(old.getPrerequisites()); dependencies.add(key(source.containerBookingId(),flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null)); + old.setPrerequisites(List.copyOf(dependencies)); update=true; + } + old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources()); + if(type==Type.DELIVERY) old.setTimezone(requirement.timezone()); + if(update) store.save(old); + } + } + private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) { + var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey())); + state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion()); + state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo()))); + if(requirement!=null) { state.setRequirementKey(requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind()); + state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources()); + if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); } + return state; + } + void observe(List states,Map facts,Actor actor) { + var affected=new LinkedHashSet(); + for(var state:states) { + if(state.isRetired()) continue; + var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue; + if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) { + affected.add(state.getWorkItemKey()); + expandDependents(state.getWorkItemKey(),states,affected); + } + } + for(var state:states) { + if(state.isRetired()) continue; + var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue; + boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null; + boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact); + if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); } + if(changed || baseline || review) { + state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state); + store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null); + } + } + } + static void expandDependents(String key,List states,Set affected) { + var queue=new ArrayDeque(); queue.add(key); var seen=new HashSet(); + var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue; + for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey()); + } + } + private void reconcileTransport(List all,Map facts) { + var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null) + .collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey())); + var liveCanonical=new HashSet(); + for(var entry:members.entrySet()) { + var group=entry.getValue(); group.sort(Comparator.comparing(AppointmentWorkState::getWorkItemKey)); + String canonicalKey=key("TRIP:"+entry.getKey(),Type.TRANSPORT,null); liveCanonical.add(canonicalKey); + var canonical=all.stream().filter(s->s.getWorkItemKey().equals(canonicalKey)).findFirst().orElse(null); + boolean fresh=canonical==null; + if(fresh) { canonical=store.read(store.write(group.get(0)),AppointmentWorkState.class); canonical.setWorkItemKey(canonicalKey); canonical.setVersion(0); canonical.setTripKey(entry.getKey()); canonical.setRequirementKey(null); canonical.setBatchKey(null); canonical.setBatchNo(null); canonical.setReplacementKeys(List.of()); } + var containers=group.stream().flatMap(s->s.getContainers().stream()).distinct().sorted(Comparator.comparing(ContainerReference::containerBookingId)).toList(); + var batches=group.stream().map(AppointmentWorkState::getBatchKey).filter(Objects::nonNull).distinct().sorted().toList(); + var prerequisites=group.stream().flatMap(s->s.getPrerequisites().stream()).distinct().sorted().toList(); + boolean changed=canonical.isRetired() || !containers.equals(canonical.getContainers()) || !batches.equals(canonical.getBatchKeys()) || !prerequisites.equals(canonical.getPrerequisites()); + canonical.setContainers(containers); canonical.setBatchKeys(batches); canonical.setPrerequisites(prerequisites); canonical.setRetired(false); canonical.setRetirementReason(null); + canonical.setReferences(group.stream().flatMap(s->s.getReferences().stream()).distinct().toList()); + canonical.setSources(group.stream().flatMap(s->s.getSources().stream()).distinct().toList()); + if(fresh) store.insert(canonical); else if(changed) store.save(canonical); + for(var member:group) if(!member.isRetired() || !member.getReplacementKeys().equals(List.of(canonicalKey))) { member.setRetired(true); member.setRetirementReason("TRIP_LINKED"); member.setReplacementKeys(List.of(canonicalKey)); store.save(member); } + } + for(var state:all) { + if(state.getTripKey()!=null && !liveCanonical.contains(state.getWorkItemKey()) && !state.isRetired()) { state.setRetired(true); state.setRetirementReason("TRIP_UNLINKED"); store.save(state); } + if(state.getType()==Type.TRANSPORT && state.getTripKey()==null && "TRIP_LINKED".equals(state.getRetirementReason()) && facts.containsKey(state.getWorkItemKey()) && facts.get(state.getWorkItemKey()).tripKey()==null) { state.setRetired(false); state.setRetirementReason(null); state.setReplacementKeys(List.of()); store.save(state); } + } + // Keep requirement edges stable; query traversal follows retired transport replacement keys. + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java new file mode 100644 index 000000000..bd5b4410a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodic full scan also compensates remote changes and process restarts; no GET performs writes. */ +@Component +@EnableScheduling +public class AppointmentWorkSynchronizationJob { + private static final Logger log=LoggerFactory.getLogger(AppointmentWorkSynchronizationJob.class); + private final AppointmentWorkSyncService synchronization; + private String cursor; + public AppointmentWorkSynchronizationJob(AppointmentWorkSyncService synchronization) { this.synchronization=synchronization; } + @Scheduled(initialDelayString="${baiyue.appointment-work.sync-initial-delay-ms:30000}",fixedDelayString="${baiyue.appointment-work.sync-delay-ms:10000}") + public void run() { + try { + var result=synchronization.synchronize(new AppointmentWorkModels.SyncRequest(cursor,100)); + cursor=result.complete()?null:result.nextCursor(); + } catch(RuntimeException error) { + cursor=null; + log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",error.getClass().getSimpleName()); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index a8d9e405d..2088889b7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; public final class DispatchRequests { private DispatchRequests() { @@ -37,10 +38,15 @@ public final class DispatchRequests { @Size(max = 32) String vehicleAppointmentStatus, @Size(max = 64) String draftToken, @Size(max = 64) String relabelNo, - @Size(max = 500) List<@NotNull Long> returnReceiptIds) { + @Size(max = 500) List<@NotNull Long> returnReceiptIds, + @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { public PreArrange { planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); + batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); + } + public PreArrange(List planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List returnReceiptIds) { + this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of()); } public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken, String relabelNo) { @@ -74,11 +80,14 @@ public final class DispatchRequests { public record DraftGroup(@NotNull Long draftContainerId, @Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 500) List<@NotNull Long> returnReceiptIds) { + @Size(max = 500) List<@NotNull Long> returnReceiptIds, + @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { public DraftGroup { planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); + batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); } + public DraftGroup(Long id,List pallets,List receipts) { this(id,pallets,receipts,List.of()); } } public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e4bf9aa80..52151dd76 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -11,12 +11,14 @@ import java.util.List; public interface DispatchCandidateMapper { // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. String CONFIRMED_APPOINTMENT_AT = - "(SELECT MAX(fa.appointment_at) FROM ll_inbound_plan_pallet_allocation pa " + + "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + "JOIN ll_inbound_plan_line al ON al.id=pa.line_id AND al.customer_id=pa.customer_id AND al.plan_id=p.plan_id " + "JOIN ll_inbound_fba_appointment_item fi ON fi.order_id=al.order_id AND fi.customer_id=al.customer_id " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id " + "WHERE pa.pallet_id=p.id AND pa.customer_id=ip.customer_id AND fi.active=1 AND fa.appointment_status='SUCCESS' " + "AND fi.fba_warehouse_code=fa.fba_warehouse_code " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item other_batch " + + "WHERE other_batch.order_id=fi.order_id AND other_batch.customer_id=fi.customer_id AND other_batch.active=1 AND other_batch.appointment_id!=fi.appointment_id) " + "AND fa.fba_warehouse_code=COALESCE(NULLIF(TRIM(p.destination_warehouse_code),''),NULLIF(TRIM(al.fba_warehouse_code),'')))"; // Billing volume weight is never a substitute for physical cargo mass. String SOURCE_GROSS_WEIGHT = diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 74f66bfe4..bf88ae815 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -52,6 +52,8 @@ import java.util.stream.Collectors; @Service public class DispatchService { + @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; + @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges; public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; public static final String CANCELLED = "CANCELLED"; @@ -350,7 +352,7 @@ public class DispatchService { var results = new java.util.ArrayList(); for (var group : prepared) { var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null, - token, null, group.request().returnReceiptIds()); + token, null, group.request().returnReceiptIds(),group.request().batchAllocations()); var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true); results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response)); } @@ -375,6 +377,8 @@ public class DispatchService { private DispatchResponse preArrangeContainer(String customerId, String operatorId, DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) { + if(StringUtils.hasText(request.fbaAppointmentNo()) && request.batchAllocations().isEmpty()) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"预约关联必须提供不可变批次键及逐票实物分配,不能仅填写预约号"); if (StringUtils.hasText(request.relabelNo())) { if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); return preArrangeRelabel(customerId, operatorId, request); @@ -444,7 +448,7 @@ public class DispatchService { var existing = items.findByDispatchOrder(customerId, dispatch.getId()); if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()) .equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()))) - return response(dispatch, existing); + { appointmentAllocations.requireReplay(customerId,dispatch.getId(),request.batchAllocations()); return response(dispatch, existing); } } if (refreshing) { requirePalletEditingAllowed(dispatch); @@ -581,7 +585,9 @@ public class DispatchService { DispatchOrder responseOrder = preserveExactReplay ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") : dispatch; - return response(responseOrder, items.findByDispatchOrder(customerId, dispatch.getId())); + var assignedItems=items.findByDispatchOrder(customerId, dispatch.getId()); + appointmentAllocations.bind(customerId,dispatch.getId(),assignedItems.stream().map(i->new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(),request.batchAllocations()); + return response(responseOrder, assignedItems); } @Transactional @@ -736,6 +742,7 @@ public class DispatchService { LocalDateTime now = LocalDateTime.now(); matchingLocks.forEach(lock -> releaseLock(lock, now)); items.deleteById(item.getId()); + appointmentAllocations.removeItem(dispatch.getId(),item.getId()); List remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); dispatch.setItemCount(remaining.size()); @@ -775,6 +782,7 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); LocalDateTime now = LocalDateTime.now(); dispatch.setStatus(CANCELLED); + workChanges.requirementsChanged(); dispatch.setUpdatedAt(now); orders.updateById(dispatch); for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index ad64c030a..747d6f270 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -23,6 +23,7 @@ import java.util.*; @Service public class FleetTripExecutionService { + @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripMapper trips; private final FleetTripDispatchMapper bindings; private final FleetResourceMapper resources; @@ -228,6 +229,7 @@ public class FleetTripExecutionService { if (reason == null) reason = loadingReviewBlockReason(trip); if (reason != null) throw conflict(reason); // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. + for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo()); Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); trip.setStatus("DEPARTING"); if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index f9cf3a49d..01617e259 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -7,7 +7,14 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface FleetTripMapper extends BaseMapper { - @Select("SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,expected_driver_end_at,departed_at,completed_at,stops_json FROM ll_fleet_trip WHERE planned_departure_at>=#{from} AND planned_departure_at<#{to} ORDER BY planned_departure_at,trip_no LIMIT 2001") + @Select(""" + SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at, + expected_driver_end_at,departed_at,completed_at,stops_json + FROM ll_fleet_trip + WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to}) + OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}) + ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001 + """) java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") FleetTripEntity lockByNo(@Param("tripNo") String tripNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 00df73c67..a43fbc1e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -27,6 +27,8 @@ import java.util.*; @Service public class FleetTripService { + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; + @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripMapper trips; private final FleetResourceMapper resources; private final FleetTripHistoryMapper histories; @@ -205,6 +207,7 @@ public class FleetTripService { requireReason(request.reason()); if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); + appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId()); if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); var bound = bindings.lockByDispatch(dispatch.dispatchId()); if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); @@ -339,6 +342,7 @@ public class FleetTripService { record(trip, action, reason, operator); } private void record(FleetTripEntity trip, String action, String reason, String operator) { + workChanges.changed(trip); var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkSupplierDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkSupplierDirectory.java new file mode 100644 index 000000000..d673d4ad1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkSupplierDirectory.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.trip.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.SupplierDirectory; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import com.baiyue.lastleg.fleet.quote.FleetQuoteSupplierClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkSupplierDirectory implements SupplierDirectory { + private final FleetQuoteSupplierClient suppliers; + private final String token; + public AppointmentWorkSupplierDirectory(FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token:}") String token) { this.suppliers=suppliers; this.token=token; } + public Responsibility activeSupplier(String id) { + final long number; + try { number=Long.parseLong(id); if(number<=0 || !Long.toString(number).equals(id)) throw new NumberFormatException(); } + catch(RuntimeException error) { throw bad("供应商关联标识无效"); } + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var response=suppliers.get(token,number,true,"TRUCKING"); + if(response==null || !response.successful() || response.data()==null) throw unavailable("供应商权威目录读取失败"); + var supplier=response.data(); + if(supplier.supplierId()!=number || !"ACTIVE".equals(supplier.status()) || !"TRUCKING".equals(supplier.category()) || !"OVERSEAS".equals(supplier.truckingScope()) || supplier.name()==null || supplier.name().isBlank()) throw bad("还空责任方须为有效海外车队"); + return new Responsibility(ResponsibilityKind.SUPPLIER,id,supplier.name()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProvider.java new file mode 100644 index 000000000..65d9bea4d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProvider.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.fleet.trip.work; + +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkTransportProvider implements AppointmentWorkFactGateway.BusinessProvider { + private final AppointmentWorkTripMapper mapper; + private final FleetWarehouseClient warehouses; + private final AppointmentWorkCommitmentService commitments; + private final String token; + public AppointmentWorkTransportProvider(AppointmentWorkTripMapper mapper,FleetWarehouseClient warehouses,AppointmentWorkCommitmentService commitments,@Value("${baiyue.security.internal-service-token:}") String token) { this.mapper=mapper; this.warehouses=warehouses; this.commitments=commitments; this.token=token; } + public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.TRANSFER; } + public List read(List requests) { return requests.stream().map(this::read).toList(); } + private Fact read(FactRequest request) { + FleetTripEntity trip=null; + if(request.tripKey()!=null) { + try { trip=mapper.trip(Long.parseLong(request.tripKey())); } + catch(NumberFormatException error) { throw unavailable("车次关联键无效",error); } + if(trip==null) throw unavailable("已关联原车次缺失"); + } else if(request.batchKey()!=null) { + var trips=mapper.batchTrips(request.batchKey()); + if(trips.size()>1) throw conflict("同一预约批次关联多个车次,请在原业务核对分批与实物关系"); + if(!trips.isEmpty()) trip=trips.get(0); + } + if(trip==null) { + var action=new Action(ActionCode.ARRANGE_TRANSPORT,"按批次选择实物并安排车次",request.workItemKey(),new TripTarget(null,request.batchKeys())); + return new Fact(request.workItemKey(),"UNASSIGNED","UNASSIGNED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,null,null, + List.of(ORDER_READ),List.of("platform:fleet:trip:create","platform:fleet:trip:edit"),action, + request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null); + } + String timezone=timezone(trip.getWarehouseId()); + var missing=timezone==null?List.of("SOURCE_TIMEZONE"):List.of(); + var target=localSlot(trip.getPlannedDepartureAt(),trip.getExpectedDriverEndAt(),timezone); + boolean complete="COMPLETED".equals(trip.getStatus()) && trip.getCompletedAt()!=null; + var occurred=complete?trip.getCompletedAt():null; + var actual=occurred==null?null:new Actual(occurred.toInstant(ZoneOffset.UTC),null,timezone); + var execution=complete?ExecutionStatus.COMPLETED:trip.getDepartedAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING; + var action=complete?null:new Action(ActionCode.ARRANGE_TRANSPORT,"维护原车次安排",request.workItemKey(),new TripTarget(trip.getId().toString(),mapper.batches(trip.getId()))); + return new Fact(request.workItemKey(),"TRIP:"+trip.getId()+":"+trip.getRecordVersion(),commitments.impactToken("TRIP:"+trip.getId()),true, + target==null?PlanStatus.UNPLANNED:trip.getVehicleId()!=null && trip.getDriverId()!=null?PlanStatus.CONFIRMED:PlanStatus.TARGET_SET, + execution,target,trip.getVehicleId()!=null && trip.getDriverId()!=null?target:null,actual,timezone,trip.getTripNo(),trip.getId().toString(), + List.of(ORDER_READ,"platform:fleet:trip:list"),action==null?List.of():List.of("platform:fleet:trip:edit"),action, + "CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null); + } + private String timezone(Long warehouse) { + if(warehouse==null) return null; + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); + var response=warehouses.platformMetadata(token,new FleetWarehouseClient.MetadataRequest(List.of(warehouse))); + if(response==null || !response.successful() || response.data()==null || response.data().records()==null || response.data().missingIds()==null) throw unavailable("车次地点目录读取不完整"); + var matches=response.data().records().stream().filter(w->warehouse.equals(w.id())).toList(); + if(matches.size()>1 || matches.isEmpty() && !response.data().missingIds().contains(warehouse)) throw unavailable("车次地点目录遗漏请求结果"); + if(matches.isEmpty() || matches.get(0).timezone()==null) return null; + zone(matches.get(0).timezone()); return matches.get(0).timezone(); + } + private Slot localSlot(LocalDateTime start,LocalDateTime end,String timezone) { + // An absolute UTC plan cannot be relabelled as unknown local time. + if(start==null || timezone==null) return null; + var local=start.toInstant(ZoneOffset.UTC).atZone(zone(timezone)); var finish=end==null?null:end.toInstant(ZoneOffset.UTC).atZone(zone(timezone)); + return new Slot(local.toLocalDate(),local.toLocalTime(),finish==null?null:finish.toLocalDate(),finish==null?null:finish.toLocalTime(),timezone); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripChanges.java new file mode 100644 index 000000000..4e4f067df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripChanges.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.fleet.trip.work; + +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import org.springframework.stereotype.Component; +import java.util.Objects; + +@Component +public class AppointmentWorkTripChanges { + private final AppointmentWorkChanges changes; + private final AppointmentWorkCommitmentService commitments; + private final AppointmentWorkTripMapper mapper; + public AppointmentWorkTripChanges(AppointmentWorkChanges changes,AppointmentWorkCommitmentService commitments,AppointmentWorkTripMapper mapper) { this.changes=changes; this.commitments=commitments; this.mapper=mapper; } + public void changed(FleetTripEntity trip) { + changes.requirementsChanged(); + String assignment=trip.getVehicleId()==null && trip.getDriverId()==null?null:trip.getVehicleId()+":"+trip.getDriverId(); + String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId())+":"+Objects.toString(trip.getStopsJson(),"")); + // Fleet plan storage is explicitly UTC. This is an audit baseline, not a location-time projection. + var start=trip.getPlannedDepartureAt(); var end=trip.getExpectedDriverEndAt(); + var slot=start==null?null:new AppointmentWorkModels.Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),"UTC"); + commitments.record("TRIP:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,cargo,"CANCELLED".equals(trip.getStatus()))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripMapper.java new file mode 100644 index 000000000..7f4fab5a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.fleet.trip.work; + +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkTripMapper { + @Select("SELECT DISTINCT t.* FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id JOIN ll_fleet_trip t ON t.id=b.trip_id AND t.status<>'CANCELLED' WHERE a.batch_key=#{batch}") + List batchTrips(String batch); + @Select("SELECT * FROM ll_fleet_trip WHERE id=#{id}") FleetTripEntity trip(long id); + @Select("SELECT DISTINCT a.batch_key FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE b.trip_id=#{id} ORDER BY a.batch_key") + List batches(long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java index 11fd0824b..489e1f711 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java @@ -24,6 +24,12 @@ public class AppointmentSourceCreateController { requirePlatform(user); return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request)); } + @PostMapping("/repartition") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')") + public ApiResponse repartition(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody RepartitionFbaAppointmentsRequest request) { + requirePlatform(user); + return ApiResponse.success(service.repartition(String.valueOf(user.userId()),request)); + } @GetMapping("/unscheduled") @PreAuthorize("hasAuthority('platform:appointment:list')") public ApiResponse> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/FbaAppointmentBatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/FbaAppointmentBatchController.java new file mode 100644 index 000000000..f9685d71c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/FbaAppointmentBatchController.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentBatchContext; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches") +public class FbaAppointmentBatchController { + private final InboundFbaAppointmentService service; + public FbaAppointmentBatchController(InboundFbaAppointmentService service) {this.service=service;} + + @GetMapping("/{batchKey}") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) { + if(user==null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ApiResponse.success(service.batchContext(batchKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentBatchContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentBatchContext.java new file mode 100644 index 000000000..489c0fa23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentBatchContext.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.util.List; + +public record FbaAppointmentBatchContext(FbaAppointmentResponse appointment,List sources, + List allowedActions,List blockReasons) { + public record SourceAllocation(String customerId,String sourceKey,String destinationKey,Integer packageCount, + Integer originalPackages,String sourceVersion,Integer cargoVerificationVersion, + List shipmentReferences) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java index a2f2d06a4..b3713fffc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -24,9 +24,14 @@ public record FbaAppointmentResponse( LocalDateTime createdAt, LocalDateTime updatedAt, List items, - Integer version + Integer version, + List replacementAppointmentNos, + String repartitionReason, + String batchKey, + List replacementBatchKeys ) { public record Item(Long id, String sourceKey, String sourceVersion, Long orderId, String inboundCode, - String fbaWarehouseCode, String customerId, Integer packageCount) { + String fbaWarehouseCode, String customerId, Integer packageCount, + Integer cargoVerificationVersion, List shipmentReferences, String destinationKey) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java index a1c844d0d..ac822d54a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -21,7 +21,7 @@ public final class InboundPlanRequests { String destinationWarehouseCode, String warehouse, String fbaWarehouseCode, - @Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED") + @Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED|MULTIPLE") String appointmentStatus, @Pattern(regexp = "UNSCHEDULED|SCHEDULED") String dispatchStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsRequest.java new file mode 100644 index 000000000..1e4a5f343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsRequest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.util.List; + +public record RepartitionFbaAppointmentsRequest( + @NotEmpty @Size(max=20) List<@NotNull @Valid Original> originals, + @NotEmpty @Size(max=20) List<@NotNull @Valid Batch> batches, + @NotBlank @Size(max=1000) String reason, + @NotBlank @Size(max=64) String idempotencyKey +) { + public record Original(@NotBlank @Size(max=64) String appointmentNo,@NotNull @Min(0) Integer version) { } + public record Batch(LocalDate plannedDate,@Size(max=1000) String remark, + @NotEmpty @Size(max=100) List<@NotNull @Valid Allocation> allocations) { } + public record Allocation(@NotBlank @Size(max=128) String customerId,@NotBlank @Size(max=160) String sourceKey, + @NotNull @Positive Integer packageCount,@Size(max=160) String expectedSourceVersion, + @Min(1) Integer expectedVerificationVersion) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsResponse.java new file mode 100644 index 000000000..76876149a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.util.List; + +public record RepartitionFbaAppointmentsResponse(List replacedBatchKeys,List replacedAppointmentNos, + List appointments) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java index fc4bf5c49..224cc8d4d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -38,6 +38,9 @@ public class InboundFbaAppointment { private String appointmentRemark; private String fbaWarehouseCode; private Integer selectedCount; + private String replacementNosJson; + private String replacementKeysJson; + private String repartitionReason; private String createdBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java index 8832e937d..503616a53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java @@ -19,6 +19,10 @@ public class InboundFbaAppointmentItem { private String sourceVersion; @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private Integer packageCount; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private Integer cargoVerificationVersion; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String shipmentReferencesJson; private Boolean active; @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private Long orderId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 053585692..216503c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -14,11 +14,19 @@ public interface InboundFbaAppointmentQueryMapper { @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) List lockOrders(@Param("ids") List ids); - @Select("") - long countActiveSources(@Param("keys") List keys,@Param("orderIds") List orderIds,@Param("excludedId") Long excludedId); + record Allocation(long packages,long count,long unknownCount) { } + @Select("") + Allocation allocation(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey, + @Param("orderId") Long orderId,@Param("excludedId") Long excludedId); + + @Select({""}) + List relatedBatchIds(@Param("sources") List sources); @Select("") List selectedLines(@Param("lineIds") List lineIds); - @Select("") - long countActiveAppointments(@Param("lineIds") List lineIds, @Param("excludedId") Long excludedId); - @Select(""" - SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i - JOIN ll_dispatch_item di ON di.order_id=i.order_id AND di.customer_id=i.customer_id - JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND d.customer_id=di.customer_id - WHERE i.appointment_id=#{appointmentId} AND d.status<>'CANCELLED' + SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a + JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id + WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED' """) long countActiveDispatches(@Param("appointmentId") Long appointmentId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index a5e454b58..d52eb554e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -13,6 +13,7 @@ import java.util.List; @Mapper public interface InboundPlanMapper extends BaseMapper { + String APPOINTMENT_STATUS="CASE WHEN (SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=p.source_order_id AND current_item.customer_id=p.customer_id AND current_item.active=1)>1 THEN 'MULTIPLE' ELSE COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND a.replacement_nos_json IS NULL ORDER BY ai.active DESC,a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') END"; String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + "LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " + "WHERE p.customer_id=#{customerId}" + @@ -23,8 +24,7 @@ public interface InboundPlanMapper extends BaseMapper { " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + " " + - "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + - "WHERE ai.order_id=p.source_order_id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + "AND ("+APPOINTMENT_STATUS+")=#{q.appointmentStatus}" + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + "WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND ai.active=1)" + " AND p.created_at >=#{q.createdFrom}" + @@ -34,9 +34,7 @@ public interface InboundPlanMapper extends BaseMapper { "l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," + "l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," + "COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," + - "COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " + - "JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id " + - "ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" + + APPOINTMENT_STATUS+" appointment_status,p.created_at,p.updated_at" + PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}") List pageRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 13729a99f..2e24d2207 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -10,6 +10,8 @@ import java.util.List; @Mapper public interface InboundPlanQueryMapper { + String ACTIVE_APPOINTMENT_COUNT="(SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=l.order_id AND current_item.customer_id=p.customer_id AND current_item.fba_warehouse_code=l.fba_warehouse_code AND current_item.active=1)"; + String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN 'MULTIPLE' ELSE a.appointment_status END"; String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + " AND dispatch.customer_id=di.customer_id" + @@ -24,7 +26,7 @@ public interface InboundPlanQueryMapper { " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + " WHERE ai2.order_id=l.order_id AND ai2.customer_id=p.customer_id AND ai2.fba_warehouse_code=l.fba_warehouse_code" + - " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + + " AND a2.replacement_nos_json IS NULL ORDER BY ai2.active DESC,a2.created_at DESC,ai2.id DESC LIMIT 1)" + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" + " WHERE 1=1" + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + @@ -36,7 +38,7 @@ public interface InboundPlanQueryMapper { " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + " AND (ai.id IS NULL OR ai.active=0)" + - " AND a.appointment_status=#{q.appointmentStatus}" + + " AND ("+APPOINTMENT_STATUS+")=#{q.appointmentStatus}" + " AND " + SCHEDULED_DISPATCH + "" + " AND NOT " + SCHEDULED_DISPATCH + "" + " AND p.created_at >=#{q.createdFrom}" + @@ -55,8 +57,8 @@ public interface InboundPlanQueryMapper { "(SELECT i.net_weight_kg FROM ll_inbound_order i WHERE i.id=l.order_id AND i.customer_id=p.customer_id),0) netWeightKg," + "l.volume_cbm volumeCbm," + "l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," + - "p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," + - "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + + "p.urgent_flag urgent,p.status status,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN NULL ELSE a.appointment_no END appointmentNo," + + APPOINTMENT_STATUS+" appointmentStatus,CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN NULL ELSE a.appointment_at END appointmentAt," + "NULL expectedLoadingAt," + "CASE WHEN " + SCHEDULED_DISPATCH + " THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index b4998a5e3..2963f9be1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -102,6 +102,7 @@ public class ReceivingContainerService { @Resource private ContainerLoadingEvidenceMapper domesticEvidence; @Resource private InboundPdaWorkflowService workflow; @Resource private ObjectMapper sourceObjectMapper; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard workRouteGuard; private ObjectMapper objectMapper; @@ -638,6 +639,7 @@ public class ReceivingContainerService { ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); + if (lock) workRouteGuard.requireWarehouseFlow(session.getSourceContainerBookingId()); requireStableSessionFacts(session, lock); return session; } @@ -646,6 +648,7 @@ public class ReceivingContainerService { long overseasWarehouseId, boolean lock) { String bookingId = trim(sourceContainerBookingId); if (bookingId == null) throw conflict("sourceContainerBookingId is required"); + if (lock) workRouteGuard.requireWarehouseFlow(bookingId); LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(LastlegOrder::getContainerNo, containerNo) .eq(LastlegOrder::getSourceContainerBookingId, bookingId) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index ba6b1e7f0..227c0a3ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -29,12 +29,15 @@ public class InboundFbaAppointmentService { @Resource private FbaAppointmentCommandMapper commands; @Resource private AppointmentSourceService sources; @Resource private AppointmentBatchPreviewMapper flags; + @Resource private AppointmentCargoVerificationService verification; @Resource private ObjectMapper json; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw bad("日历查询范围必须为1至42天"); var query=new LambdaQueryWrapper() + .isNull(InboundFbaAppointment::getReplacementNosJson) .notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED")) .apply("COALESCE(appointment_at,planned_at,planned_date)<{1} AND (COALESCE(appointment_at,planned_at,planned_date)>={0} OR CASE WHEN appointment_at IS NOT NULL THEN appointment_end_at WHEN planned_at IS NOT NULL THEN planned_end_at ELSE NULL END>{0})",from.atStartOfDay(),to.atStartOfDay()) .orderByAsc(InboundFbaAppointment::getId); @@ -44,6 +47,7 @@ public class InboundFbaAppointmentService { public PageResult unscheduled(int page,int size) { if(page<1 || size<1 || size>100) throw bad("分页参数无效"); var query=new LambdaQueryWrapper() + .isNull(InboundFbaAppointment::getReplacementNosJson) .isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt) .isNull(InboundFbaAppointment::getPlannedDate) .notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED")); @@ -72,7 +76,7 @@ public class InboundFbaAppointmentService { request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); } private FbaAppointmentResponse create(String operator,List refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) { - var resolved=resolveForWrite(refs,null); + var resolved=resolveForWrite(refs); var group=group(resolved); if(clean(expectedOrigin)==null || clean(expectedDestination)==null) throw bad("请选择接仓点和目的仓后重新核对货物"); if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物"); @@ -83,11 +87,12 @@ public class InboundFbaAppointmentService { appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode()); appointment.setSelectedCount(resolved.size());appointment.setVersion(0); appointment.setCreatedBy(operator);appointment.setCreatedAt(now());appointment.setUpdatedAt(appointment.getCreatedAt()); - apply(appointment,details,resolved); + var evidence=verification(resolved); + apply(appointment,details,resolved,evidence); if(Set.of("FAILED","CANCELLED").contains(appointment.getAppointmentStatus())) throw bad("新安排请先保存待确认或登记已取得的正式确认"); try {appointmentMapper.insert(appointment);} catch(DuplicateKeyException duplicate){throw conflict("该目的仓的外部约号已有记录,请继续原预约");} - writeItems(appointment,resolved,List.of()); + writeItems(appointment,resolved,List.of(),evidence,Map.of()); return response(appointment,items(appointment.getId())); } @@ -96,11 +101,56 @@ public class InboundFbaAppointmentService { var appointment=find(appointmentNo,false);if(appointment==null) throw missing(); return response(appointment,items(appointment.getId())); } + /** Internal read boundary. Caller authorizes source access; includes cancelled/replaced history so demand is not silently erased. */ + @Transactional(readOnly=true) + public List relatedBatchSnapshots(List refs) { + if(refs==null || refs.isEmpty()) return List.of(); + if(refs.size()>100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) throw bad("每次最多读取100个完整原来源的关联批次"); + var ids=queryMapper.relatedBatchIds(refs.stream().distinct().toList()); + if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); + return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); + } + @Transactional(readOnly=true) + public FbaAppointmentBatchContext batchContext(String key) { + final long id; + try {id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id<=0 || !key.equals(batchKey(id))) throw new IllegalArgumentException();} + catch(RuntimeException error) {throw bad("批次关联键无效");} + var appointment=appointmentMapper.selectById(id);if(appointment==null) throw missing(); + var rows=items(id);var snapshot=response(appointment,rows); + var reasons=new ArrayList();var actions=new ArrayList(); + if(appointment.getReplacementNosJson()!=null) reasons.add("原批次已被分批或合并,请进入替代批次"); + if(!"PENDING".equals(appointment.getAppointmentStatus())) reasons.add("仅未取得正式确认的活动批次可分批或合并"); + if(queryMapper.countActiveDispatches(id)>0) reasons.add("已关联排车,请先处理运输安排"); + if(!reasons.isEmpty()) return new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.copyOf(reasons)); + var refs=rows.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList(); + var resolved=sources.resolveBatch(refs);var evidence=verification(resolved); + if(resolved.stream().anyMatch(s->!Objects.equals(s.originWarehouseId(),appointment.getOriginWarehouseId()) || !Objects.equals(s.destinationWarehouseCode(),appointment.getFbaWarehouseCode()))) reasons.add("原来源起讫点已变化,请先复核原批次"); + var originalItems=rows.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); + var allocations=new ArrayList(); + for(var source:resolved) { + var item=originalItems.get(source.sourceKey());var verified=evidence.get(source.sourceKey()); + var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),id); + Integer total=quantity(source,evidence),count=item.getPackageCount(); + if(count==null && total!=null && other.unknownCount()==0 && other.packages()0 || (long)count+other.packages()>total) reasons.add("原来源箱数或分配总量待核实"); + allocations.add(new FbaAppointmentBatchContext.SourceAllocation(source.customerId(),source.sourceKey(),source.destinationKey(),count, + source.packages(),source.sourceVersion(),verified==null?null:verified.version(),verified==null?List.of():verified.shipmentReferences())); + } + if(resolved.stream().allMatch(AppointmentSourceService.Source::canFollowup)) actions.add("VERIFY_CARGO"); + if(reasons.isEmpty()) { + if(allocations.stream().mapToLong(a->a.packageCount()).sum()>1) actions.add("SPLIT"); + actions.add("MERGE"); + } + return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList()); + } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) { if ("CANCELLED".equals(status(request.details().appointmentStatus()))) throw bad("取消预约请使用取消操作并填写实际取消原因"); return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{ var appointment=find(appointmentNo,true);if(appointment==null) throw missing(); + requireCurrent(appointment); if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作"); if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约"); var current=items(appointment.getId()); @@ -112,11 +162,12 @@ public class InboundFbaAppointmentService { current.forEach(item->{item.setActive(false);itemMapper.updateById(item);}); } else { var refs=current.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList(); - var resolved=resolveForWrite(refs,appointment.getId()); + var resolved=resolveForWrite(refs); if(!Objects.equals(group(resolved).originWarehouseId(),appointment.getOriginWarehouseId())) throw conflict("接仓点已变化,请重新核对安排"); if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认,请记录取消原因后重新安排"); - apply(appointment,request.details(),resolved); - writeItems(appointment,resolved,current); + var evidence=verification(resolved); + apply(appointment,request.details(),resolved,evidence); + writeItems(appointment,resolved,current,evidence,Map.of()); } appointment.setVersion(request.version()+1);appointment.setUpdatedAt(now()); if(appointmentMapper.update(appointment,new LambdaQueryWrapper().eq(InboundFbaAppointment::getId,appointment.getId()).eq(InboundFbaAppointment::getVersion,request.version()))!=1) throw conflict("预约版本已变化"); @@ -130,6 +181,7 @@ public class InboundFbaAppointmentService { var cancellation=new CancelFbaAppointmentRequest(version,request.cancelReason().trim()); command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,cancellation,()->{ var appointment=find(appointmentNo,true);if(appointment==null) throw missing(); + requireCurrent(appointment); if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作"); if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车"); appointment.setAppointmentStatus("CANCELLED"); @@ -139,7 +191,106 @@ public class InboundFbaAppointmentService { }); } - private List resolveForWrite(List refs,Long excludedId) { + @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) { + validateRepartition(request); + return executeCommand("REPARTITION",operator,request.idempotencyKey(),request,RepartitionFbaAppointmentsResponse.class, + result->result.appointments().get(0).appointmentNo(),()->{ + var originals=new ArrayList(); + var originalItems=new LinkedHashMap>(); + var refs=new LinkedHashMap(); + for(var intent:request.originals().stream().sorted(Comparator.comparing(RepartitionFbaAppointmentsRequest.Original::appointmentNo)).toList()) { + var row=find(intent.appointmentNo(),true);if(row==null) throw missing(); + requireCurrent(row); + if(!Objects.equals(row.getVersion(),intent.version())) throw conflict("原预约已更新,请重新核对分批资料"); + if(!"PENDING".equals(row.getAppointmentStatus()) || row.getConfirmationNo()!=null || row.getAppointmentAt()!=null) + throw conflict("仅未取得正式确认的批次可以分批或合并,请先处理已确认预约"); + if(queryMapper.countActiveDispatches(row.getId())>0) throw conflict("原批次已有关联排车,请先处理运输安排"); + if(!originals.isEmpty() && (!Objects.equals(originals.get(0).getOriginWarehouseId(),row.getOriginWarehouseId()) + || !Objects.equals(originals.get(0).getFbaWarehouseCode(),row.getFbaWarehouseCode()))) throw bad("仅同一起运仓、同一目的仓可以合并"); + var items=items(row.getId()); + if(items.isEmpty() || items.stream().anyMatch(item->!Boolean.TRUE.equals(item.getActive()))) throw conflict("原批次货物已变化,请重新核对"); + for(var item:items) { + var ref=new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+row.getFbaWarehouseCode()); + var previous=refs.putIfAbsent(item.getSourceKey(),ref); + if(previous!=null && !previous.equals(ref)) throw conflict("同一原来源的客户或目的地不一致"); + } + originals.add(row);originalItems.put(row.getId(),items); + } + if(refs.size()>100) throw bad("一次最多重新分配100票原来源"); + var resolved=resolveForWrite(List.copyOf(refs.values())); + var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); + var grouped=group(resolved); + if(!Objects.equals(grouped.originWarehouseId(),originals.get(0).getOriginWarehouseId()) + || !Objects.equals(grouped.destinationKey(),"FBA:"+originals.get(0).getFbaWarehouseCode())) throw conflict("原来源起讫点已变化,请先复核原批次"); + var evidence=verification(resolved); + var totals=new LinkedHashMap(); + for(var rows:originalItems.values()) for(var item:rows) { + var source=bySource.get(item.getSourceKey()); + if(!Objects.equals(item.getSourceVersion(),source.sourceVersion())) throw conflict("原来源版本已变化,请先复核原批次"); + Integer count=item.getPackageCount(); + if(count==null) { + var other=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),item.getAppointmentId()); + var total=quantity(source,evidence); + if(total==null || other.unknownCount()>0 || other.packages()>=total) throw conflict("原批次箱数尚未核实,不能分批"); + count=Math.toIntExact(total-other.packages()); + } + if(count<=0) throw conflict("原批次箱数无效,请核实"); + totals.merge(source.sourceKey(),count.longValue(),Long::sum); + } + var allocated=new LinkedHashMap(); + for(var batch:request.batches()) for(var allocation:batch.allocations()) { + var source=bySource.get(allocation.sourceKey()); + if(source==null || !Objects.equals(source.customerId(),allocation.customerId())) throw bad("新批次只能使用原批次的真实来源及客户归属"); + var verified=evidence.get(source.sourceKey()); + if(!Objects.equals(source.sourceVersion(),allocation.expectedSourceVersion()) + || !Objects.equals(verified==null?null:verified.version(),allocation.expectedVerificationVersion())) throw conflict("所选来源或货物核实版本已变化,请刷新后重新分配"); + allocated.merge(source.sourceKey(),allocation.packageCount().longValue(),Long::sum); + } + if(!totals.equals(allocated)) throw bad("分批或合并前后每票原来源的分配箱数必须完全相等"); + // Release only these old batches while holding every original source lock. All inserts and replacements roll back together. + originalItems.values().forEach(rows->rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);})); + var replacements=new ArrayList(); + for(var batch:request.batches()) { + var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList(); + var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount)); + var row=new InboundFbaAppointment();row.setId(IdWorker.getId()); + row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0); + row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt()); + apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence); + appointmentMapper.insert(row);writeItems(row,rows,List.of(),evidence,quantities); + replacements.add(response(row,items(row.getId()))); + } + String replacementsJson,replacementKeysJson; + try {replacementsJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::appointmentNo).toList()); + replacementKeysJson=json.writeValueAsString(replacements.stream().map(FbaAppointmentResponse::batchKey).toList());} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("分批关系无法保存",error);} + for(var row:originals) { + row.setReplacementNosJson(replacementsJson);row.setReplacementKeysJson(replacementKeysJson);row.setRepartitionReason(request.reason().trim());row.setVersion(row.getVersion()+1);row.setUpdatedAt(now()); + if(appointmentMapper.updateById(row)!=1) throw conflict("原批次替代关系未保存"); + } + return new RepartitionFbaAppointmentsResponse(originals.stream().map(row->batchKey(row.getId())).toList(),originals.stream().map(InboundFbaAppointment::getAppointmentNo).toList(),List.copyOf(replacements)); + }); + } + private static void validateRepartition(RepartitionFbaAppointmentsRequest request) { + if(request==null || request.originals()==null || request.batches()==null || request.originals().isEmpty() || request.batches().isEmpty() + || request.originals().size()>20 || request.batches().size()>20 || clean(request.reason())==null || request.reason().length()>1000 + || clean(request.idempotencyKey())==null || request.idempotencyKey().length()>64) throw bad("请填写有效原批次、新批次、调整原因和请求标识"); + if(!(request.originals().size()==1 && request.batches().size()>1 || request.originals().size()>1 && request.batches().size()==1)) + throw bad("分批须为一批分为多批,合并须为多批合为一批"); + var originals=new HashSet(); + for(var row:request.originals()) if(row==null || clean(row.appointmentNo())==null || row.version()==null || row.version()<0 || !originals.add(row.appointmentNo())) throw bad("请选择不重复的原预约及有效版本"); + for(var batch:request.batches()) { + if(batch==null || batch.allocations()==null || batch.allocations().isEmpty() || batch.allocations().size()>100 || batch.remark()!=null && batch.remark().length()>1000) throw bad("每批请选择1至100票来源"); + var keys=new HashSet(); + for(var row:batch.allocations()) if(row==null || clean(row.customerId())==null || clean(row.sourceKey())==null + || row.packageCount()==null || row.packageCount()<=0 || row.expectedVerificationVersion()!=null && row.expectedVerificationVersion()<1 + || !keys.add(row.sourceKey())) throw bad("同批来源不可重复,每票箱数必须为正整数"); + } + } + + private List resolveForWrite(List refs) { sources.lockForWrite(refs); var resolved=sources.resolveBatch(refs); var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().sorted().toList(); @@ -153,14 +304,26 @@ public class InboundFbaAppointmentService { if(states.size()!=ids.size() || states.stream().anyMatch(s->s.detained() || s.mixedDestinations())) throw conflict("货物暂扣或含多个目的地,请先核对"); } if(resolved.stream().anyMatch(s->!s.canFollowup())) throw conflict("所选来源不再具备安排资格"); - if(queryMapper.countActiveSources(resolved.stream().map(AppointmentSourceService.Source::sourceKey).toList(),ids,excludedId)>0) throw conflict("所选货物已有有效预约安排,请继续原单"); return resolved; } private AppointmentPlanningPolicy.Group group(List rows) { try { return AppointmentPlanningPolicy.group(rows.stream().map(s->new AppointmentPlanningPolicy.Cargo(s.sourceKey(),s.customerId(),s.originWarehouseId(),s.destinationKey(),"US","US",s.packages(),null,null,s.canFollowup(),String.join(";",s.blockReasons()))).toList()); } catch(IllegalArgumentException error) {throw bad(error.getMessage());} } - private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List rows) { + private Map verification(List rows) { + var result=new HashMap(); + for(var source:rows) { + var current=verification.current(source); + if(current!=null) result.put(source.sourceKey(),current); + } + return result; + } + private static Integer quantity(AppointmentSourceService.Source source,Map evidence) { + var verified=evidence.get(source.sourceKey()); + return verified==null?source.packages():verified.packageCount(); + } + private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List rows, + Map evidence) { String status=status(details.appointmentStatus()); String timezone=sources.destinationTimezone(appointment.getFbaWarehouseCode()); if(details.plannedAt()==null && details.plannedEndAt()!=null || details.plannedAt()!=null && details.plannedEndAt()!=null && !details.plannedEndAt().isAfter(details.plannedAt())) throw bad("计划结束时间必须晚于开始时间"); @@ -171,7 +334,11 @@ public class InboundFbaAppointmentService { if("SUCCESS".equals(status)) { if(clean(details.confirmationNo())==null || details.appointmentAt()==null) throw bad("登记对方确认时请填写外部约号和确认时间"); var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); - if(ids.size()!=rows.size() || rows.stream().anyMatch(s->s.packages()==null || s.packages()<=0) || queryMapper.countMissingShipmentReferences(ids)>0) throw bad("正式确认前请核实货量并补齐真实FBA货件号或追踪号"); + var unverifiedIds=rows.stream().filter(s->!evidence.containsKey(s.sourceKey())).map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); + if(ids.size()!=rows.size() || rows.stream().anyMatch(s->quantity(s,evidence)==null || quantity(s,evidence)<=0) + || evidence.values().stream().anyMatch(v->v.shipmentReferences().isEmpty()) + || !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>0) + throw bad("正式确认前请核实货量并补齐各票FBA货件号或追踪号"); final AppointmentTimePolicy.Window window; try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));} catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());} @@ -182,32 +349,54 @@ public class InboundFbaAppointmentService { appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); } } - private void writeItems(InboundFbaAppointment appointment,List rows,List previous) { + private void writeItems(InboundFbaAppointment appointment,List rows,List previous, + Map evidence,Map requested) { var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); for(var source:rows) { var item=existing.get(source.sourceKey());boolean fresh=item==null; + var allocated=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),appointment.getId()); + Integer total=quantity(source,evidence); + if(allocated.unknownCount()>0) throw conflict("原来源已有箱数待核实的安排,请先核对原预约"); + Integer count=requested.get(source.sourceKey()); + if(count==null && !fresh) count=item.getPackageCount(); + if(count==null && total!=null) { + long remaining=total.longValue()-allocated.packages(); + if(remaining<=0) throw conflict("原来源箱数已分配完,请继续已有预约"); + count=Math.toIntExact(remaining); + } + if(total==null && (count!=null || allocated.count()>0)) throw conflict("原来源总箱数待核实,不能继续分配"); + if(count!=null && (count<=0 || (long)count+allocated.packages()>total)) throw conflict("本批及其他活动预约的分配总量超过原来源可分配箱数"); if(fresh){item=new InboundFbaAppointmentItem();item.setId(IdWorker.getId());item.setAppointmentId(appointment.getId());item.setCreatedAt(now());} item.setSourceKey(source.sourceKey());item.setSourceVersion(source.sourceVersion());item.setCustomerId(source.customerId());item.setOrderId(source.orderId());item.setInboundCode(source.inboundCode()); - item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(source.packages());item.setActive(true); + item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(count);item.setActive(true); + var verified=evidence.get(source.sourceKey()); + item.setCargoVerificationVersion(verified==null?null:verified.version()); + try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);} catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");} } } private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier action) { + return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action); + } + private T executeCommand(String operation,String operator,String key,Object request,Class type, + java.util.function.Function appointmentNumber,Supplier action) { if(clean(operator)==null || clean(key)==null || key.length()>128) throw bad("缺少操作人或有效请求标识"); final String hash; try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+json.writeValueAsString(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));} catch(Exception error){throw new IllegalStateException("无法记录预约命令",error);} var existing=commands.find(key); - if(existing!=null)return replay(existing,hash); + if(existing!=null)return replay(existing,hash,type); try {commands.begin(key,hash,operation,operator,now());} - catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);} + catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash,type);} var response=action.get(); - try {if(commands.finish(key,response.appointmentNo(),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");} + workChanges.changed(response); + try {if(commands.finish(key,appointmentNumber.apply(response),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);} return response; } - private String commandSnapshot(FbaAppointmentResponse response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException { + private String commandSnapshot(Object response,Object request) throws com.fasterxml.jackson.core.JsonProcessingException { var node=json.valueToTree(response); if(request instanceof CancelFbaAppointmentRequest cancellation) ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("cancelReason",cancellation.cancelReason()); return json.writeValueAsString(node); @@ -241,9 +430,9 @@ public class InboundFbaAppointmentService { }).toList(); return new PageResult<>(records,page,size,total,(total+size-1)/size); } - private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) { + private T replay(FbaAppointmentCommandMapper.Command value,String hash,Class type) { if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对"); - try {return readSnapshot(value.responseJson());} + try {var node=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(value.responseJson());node.remove("cancelReason");return json.treeToValue(node,type);} catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);} } private InboundFbaAppointment find(String number,boolean lock) { @@ -259,8 +448,20 @@ public class InboundFbaAppointmentService { private FbaAppointmentResponse response(InboundFbaAppointment row,List items) { return new FbaAppointmentResponse(row.getId(),row.getAppointmentNo(),row.getConfirmationNo(),row.getPlannedDate(),row.getPlannedAt(),row.getPlannedEndAt(),row.getAppointmentAt(),row.getAppointmentEndAt(), utc(row.getAppointmentStartUtc()),utc(row.getAppointmentEndUtc()),row.getTimezone(),row.getAppointmentStatus(),row.getAppointmentRemark(),row.getOriginWarehouseId(),row.getFbaWarehouseCode(),row.getSelectedCount(),row.getCreatedBy(),row.getCreatedAt(),row.getUpdatedAt(), - items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount())).toList(),row.getVersion()); + items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount(),item.getCargoVerificationVersion(),shipmentReferences(item),"FBA:"+item.getFbaWarehouseCode())).toList(),row.getVersion(),stringList(row.getReplacementNosJson()),row.getRepartitionReason(),batchKey(row.getId()),stringList(row.getReplacementKeysJson())); } + private List shipmentReferences(InboundFbaAppointmentItem item) { + return stringList(item.getShipmentReferencesJson()); + } + private List stringList(String value) { + if(value==null) return List.of(); + try {return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){});} + catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("预约货件快照不可读",error);} + } + private static void requireCurrent(InboundFbaAppointment appointment) { + if(appointment.getReplacementNosJson()!=null) throw conflict("该批次已被分批或合并,请进入替代批次继续操作"); + } + public static String batchKey(Long id) {return "FBA_APPOINTMENT:"+id;} private static String clean(String value){return value==null || value.isBlank()?null:value.trim();} private static String status(String value){try{return FbaAppointmentStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)).name();}catch(RuntimeException error){throw bad("请选择有效预约状态");}} private static ZoneOffset offset(String value){return clean(value)==null?null:ZoneOffset.of(value);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java new file mode 100644 index 000000000..c26b9b5d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.RepartitionFbaAppointmentsResponse; +import org.springframework.stereotype.Component; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +@Component +public class AppointmentWorkAppointmentChanges { + private final AppointmentWorkCommitmentService commitments; + private final AppointmentWorkChanges changes; + public AppointmentWorkAppointmentChanges(AppointmentWorkCommitmentService commitments,AppointmentWorkChanges changes) { this.commitments=commitments; this.changes=changes; } + public void changed(Object response) { + changes.requirementsChanged(); + if(response instanceof FbaAppointmentResponse appointment) record(appointment); + else if(response instanceof RepartitionFbaAppointmentsResponse partition) partition.appointments().forEach(this::record); + else throw new IllegalArgumentException("预约命令缺少原业务响应"); + } + private void record(FbaAppointmentResponse a) { + boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()); + var cargo=a.items().stream().map(i->List.of(i.customerId(),i.sourceKey(),Objects.toString(i.destinationKey(),""),Objects.toString(i.packageCount(),""),Objects.toString(i.shipmentReferences(),""))).sorted(Comparator.comparing(Object::toString)).toList(); + commitments.record(a.batchKey(),new AppointmentWorkCommitmentService.Commitment(cancelled?null:target(a),cancelled?null:confirmed(a),null,AppointmentWorkPolicy.digest(cargo.toString()),cancelled)); + } + public static Slot target(FbaAppointmentResponse a) { + return a.plannedAt()!=null?slot(a.plannedAt(),a.plannedEndAt(),a.timezone()):a.plannedDate()==null?null:new Slot(a.plannedDate(),null,null,null,a.timezone()); + } + public static Slot confirmed(FbaAppointmentResponse a) { return "SUCCESS".equals(a.appointmentStatus())?slot(a.appointmentAt(),a.appointmentEndAt(),a.timezone()):null; } + public static Slot slot(LocalDateTime start,LocalDateTime end,String timezone) { return start==null?null:new Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),timezone); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java new file mode 100644 index 000000000..9ab6a99cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentWorkDeliveryMapper { + record CustomerDelivery(long orderId,String customerId,long pickedPackages,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt,LocalDateTime plannedDeliveryAt) { } + String CUSTOMER_SELECT="SELECT o.id order_id,o.customer_id,SUM(a.packages) picked_packages,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at,x.planned_delivery_at FROM ll_order o JOIN ll_dispatch_pda_pick_order a ON a.order_id=o.id AND a.customer_id=o.customer_id JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status<>'CANCELLED' JOIN ll_delivery_order x ON x.dispatch_order_id=i.dispatch_order_id AND x.customer_id=i.customer_id AND x.destination_type='PRIVATE' AND x.delivery_address=i.delivery_address AND (x.postal_code=i.postal_code OR (x.postal_code IS NULL AND i.postal_code IS NULL)) "; + String CUSTOMER_GROUP=" GROUP BY o.id,o.customer_id,x.id,x.delivery_no,x.status,x.package_count,x.signed_packages,x.signed_at,x.planned_delivery_at ORDER BY o.id,x.id LIMIT 1001"; + @Select(CUSTOMER_SELECT+" WHERE o.source_container_booking_id=#{container} AND o.source_inbound_id=#{inbound}"+CUSTOMER_GROUP) + List customer(@Param("container") String container,@Param("inbound") String inbound); + @Select(CUSTOMER_SELECT+" WHERE CONCAT('DISPATCH:',i.dispatch_order_id)=#{batch}"+CUSTOMER_GROUP) + List privateBatch(String batch); + record Delivery(String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { } + @Select("SELECT b.customer_id,b.source_key,b.destination_key,b.package_count allocated_packages,d.status dispatch_status,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id AND d.status<>'CANCELLED' LEFT JOIN ll_delivery_order x ON x.dispatch_order_id=b.dispatch_id AND x.customer_id=b.customer_id AND CONCAT('FBA:',x.destination_warehouse_code)=b.destination_key WHERE b.batch_key=#{batch} ORDER BY b.dispatch_item_id,b.source_key,x.id LIMIT 1001") + List batch(String batch); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java new file mode 100644 index 000000000..ae617b403 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; +import static com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.*; + +@Component +public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGateway.BusinessProvider { + private final InboundFbaAppointmentService appointments; + private final AppointmentCargoVerificationService cargo; + private final AppointmentWorkCommitmentService commitments; + private final AppointmentWorkDeliveryMapper deliveries; + private final AppointmentWorkSourceMapper sourceMapping; + public AppointmentWorkDeliveryProvider(InboundFbaAppointmentService appointments,AppointmentCargoVerificationService cargo,AppointmentWorkCommitmentService commitments,AppointmentWorkDeliveryMapper deliveries,AppointmentWorkSourceMapper sourceMapping) { + this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping; + } + public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; } + public List read(List requests) { return requests.stream().map(this::read).toList(); } + private Fact read(FactRequest request) { + if(request.batchKey()==null) return unbatched(request); + if(request.batchKey().startsWith("DISPATCH:")) return customerBatch(request); + var context=appointments.batchContext(request.batchKey()); var a=context.appointment(); + boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()); + var target=cancelled?null:target(a); var confirmed=cancelled?null:confirmed(a); + var rows=deliveries.batch(a.batchKey()); + if(rows.size()>1000) throw unavailable("批次派送事实超过完整读取上限"); + boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages())); + // Every original source allocation must have real dispatch coverage, not just one signed fragment. + for(var item:a.items()) { + long allocated=rows.stream().filter(d->item.customerId().equals(d.customerId()) && item.sourceKey().equals(d.sourceKey()) && item.destinationKey().equals(d.destinationKey())).mapToLong(d->d.allocatedPackages()).sum(); + if(item.packageCount()==null || allocated!=item.packageCount()) complete=false; + } + var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.Delivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null; + var execution=complete?ExecutionStatus.COMPLETED:rows.stream().anyMatch(d->Set.of("DEPARTED","PARTIAL_DEPARTED").contains(d.dispatchStatus()))?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING; + var blocks=new ArrayList(); + if(cancelled) blocks.add("原预约已取消或失败,需求须重新安排"); + if(!a.replacementBatchKeys().isEmpty()) blocks.add("原批次已替代,请进入新批次"); + var sourceRefs=a.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).toList(); + var action=complete?null:new Action(confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs)); + var references=new ArrayList(); references.add(new BusinessReference("FBA_APPOINTMENT",a.batchKey(),a.appointmentNo(),"送仓预约")); + rows.stream().filter(d->d.deliveryNo()!=null).map(d->new BusinessReference("DELIVERY",d.deliveryNo(),d.deliveryNo(),"实际派送")).distinct().forEach(references::add); + return new Fact(request.workItemKey(),digest(a.batchKey()+":"+a.version()+":"+rows),commitments.impactToken(a.batchKey()),true, + confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,a.timezone(),a.appointmentNo(),null, + List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks, + actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null); + } + private Fact unbatched(FactRequest request) { + if(request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { + var mapped=sourceMapping.containerSources(request.containerBookingId()); + if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); + var related=mapped.stream().filter(s->request.requirementKey().equals("INBOUND:"+s.sourceInboundId())).toList(); + if(!related.isEmpty() && related.stream().allMatch(s->"PRIVATE".equals(s.destinationType()))) return customer(request,related); + } + var blocks=new ArrayList(); var missing=new ArrayList(); Action action=null; + var fingerprints=new ArrayList(); + if(request.sources().isEmpty()) { blocks.add("尾程货源尚未建立,请先核对整柜来源与入仓计划"); missing.add("LASTLEG_SOURCE"); } + else { + for(var source:request.sources()) { + var context=cargo.get(source.sourceKey(),source.customerId(),source.destinationKey()); + fingerprints.add(context.toString()); blocks.addAll(context.blockReasons()); + } + var first=request.sources().get(0); + action=new Action(ActionCode.VERIFY_CARGO,"核实货物并建立预约",request.workItemKey(),new CargoTarget(first)); + } + return new Fact(request.workItemKey(),digest(request.requirementKey()+":"+fingerprints),"UNBATCHED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING, + null,null,null,null,null,null,List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action, + blocks.stream().distinct().toList(),missing,List.of(),null); + } + private Fact customer(FactRequest request,List sources) { + var rows=deliveries.customer(request.containerBookingId(),request.requirementKey().substring(8)); + var expected=sources.stream().map(s->new Expected(s.id(),s.customerId(),s.packageCount()==null?null:s.packageCount().longValue())).toList(); + return customerFact(request,rows,expected,null); + } + private record Expected(long orderId,String customerId,Long packages) { } + private Fact customerBatch(FactRequest request) { + var sources=sourceMapping.privateBatch(request.batchKey()); + if(sources.size()>1000) throw unavailable("客户仓实物批次超过完整读取上限"); + // A complete scan may not have reached this changed dispatch's container yet. Represent its + // removal explicitly so compensation can advance to that page and restore the original demand. + if(sources.isEmpty() || sources.stream().anyMatch(s->"CANCELLED".equals(s.status()))) + return new Fact(request.workItemKey(),digest("REMOVED:"+sources),"REMOVED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null, + sources.isEmpty()?null:sources.get(0).dispatchNo(),null,List.of(ORDER_READ),List.of(),null,List.of("原客户仓排车已取消或移除实物,待完整需求同步"),List.of(),List.of(),null); + return customerFact(request,deliveries.privateBatch(request.batchKey()),sources.stream().map(s->new Expected(s.orderId(),s.customerId(),s.packages())).toList(),sources.get(0).dispatchNo()); + } + private Fact customerFact(FactRequest request,List rows,List sources,String dispatchNo) { + if(rows.size()>1000) throw unavailable("客户仓派送事实超过完整读取上限"); + boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages())); + for(var source:sources) { + long delivered=rows.stream().filter(r->r.orderId()==source.orderId() && source.customerId().equals(r.customerId())).mapToLong(AppointmentWorkDeliveryMapper.CustomerDelivery::pickedPackages).sum(); + if(source.packages()==null || source.packages()<=0 || delivered!=source.packages()) complete=false; + } + if(rows.stream().anyMatch(r->sources.stream().noneMatch(s->s.orderId()==r.orderId() && s.customerId().equals(r.customerId())))) complete=false; + var dates=rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::plannedDeliveryAt).filter(Objects::nonNull).distinct().sorted().toList(); + var target=dates.isEmpty()?null:slot(dates.get(0),null,null); + var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.CustomerDelivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null; + var refs=new ArrayList(); + if(dispatchNo!=null) refs.add(new BusinessReference("DISPATCH",request.batchKey(),dispatchNo,"客户仓排车")); + rows.stream().map(r->new BusinessReference("DELIVERY",r.deliveryNo(),r.deliveryNo(),"客户仓实际派送")).distinct().forEach(refs::add); + sources.stream().map(s->new BusinessReference("CUSTOMER",s.customerId(),null,"所属客户")).distinct().forEach(refs::add); + var action=rows.isEmpty() || complete?null:new Action(ActionCode.VIEW_DETAIL,"查看原派送进度",request.workItemKey(),new ReadTarget(List.copyOf(refs))); + return new Fact(request.workItemKey(),digest(sources+":"+rows),digest(sources+":"+dates),!rows.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET, + complete?ExecutionStatus.COMPLETED:rows.isEmpty()?ExecutionStatus.PENDING:ExecutionStatus.IN_PROGRESS,target,null,actual,null,dispatchNo!=null?dispatchNo:rows.size()==1?rows.get(0).deliveryNo():null,null, + List.of(ORDER_READ),action==null?List.of():List.of("platform:overseas-warehouse:delivery:list"),action, + dates.size()>1?List.of("同一收货需求存在多个派送日期,请在原派送单核对"):List.of(),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(refs),null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java new file mode 100644 index 000000000..6e4c36540 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPort { + private final AppointmentWorkDispatchMapper mapper; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + private final AppointmentWorkChanges changes; + public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes) { + this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes; + } + public void bind(String customer,long dispatch,List items,List allocations) { + if(allocations==null) throw bad("批次实物分配清单必填"); + if(allocations.isEmpty()) { + if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配"); + for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId()); + var privateRows=new ArrayList(); + for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item)); + if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); } + return; + } + if(allocations.size()>1000) throw bad("批次实物分配最多1000条"); + var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i)); + var seen=new HashSet(); + for(var row:allocations) { + if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘"); + if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复"); + } + var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList()); + for(var batch:batches) lock(batch); + var persisted=new ArrayList(); + for(var item:byPallet.values()) { + var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList(); + if(selected.isEmpty()) { + var privateRows=privateAllocation(customer,dispatch,item); + if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配"); + persisted.addAll(privateRows);continue; + } + validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false); + for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount())); + } + for(var batch:batches) { + var context=appointments.batchContext(batch); var appointment=context.appointment(); + if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定取消或被替代的预约批次"); + var grouped=allocations.stream().filter(a->batch.equals(a.batchKey())).collect(Collectors.groupingBy(a->a.sourceKey()+"/"+a.destinationKey())); + for(var group:grouped.values()) { + var ref=group.get(0); + var original=appointment.items().stream().filter(i->customer.equals(i.customerId()) && ref.sourceKey().equals(i.sourceKey()) && ref.destinationKey().equals(i.destinationKey())).findFirst().orElseThrow(()->conflict("实物来源不属于所选预约批次")); + long count=group.stream().mapToLong(Allocation::packageCount).sum(); + if(original.packageCount()==null || mapper.allocated(batch,customer,ref.sourceKey(),ref.destinationKey(),dispatch)+count>original.packageCount()) throw conflict("所选实物超过该批次逐票分配量"); + } + } + mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged(); + } + public void requireReplay(String customer,long dispatch,List allocations) { + if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致"); + var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:")) + .map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(); + if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配"); + } + private void validatePhysical(String customer,PhysicalItem item,List selected,boolean received,boolean picked) { + var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer); + if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次"); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var remaining=new HashMap(); + for(int i=0;in!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致"); + if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致"); + } + public void requireDeparture(String dispatchNo) { + var rows=mapper.bindings(dispatchNo); + var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet()); + for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) { + requireNoBatch(item.customerId(),item.planPalletId()); + if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); + } + for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { + if(batch.startsWith("DISPATCH:")) continue; + lock(batch); var a=appointments.batchContext(batch).appointment(); + if(!"SUCCESS".equals(a.appointmentStatus()) || a.appointmentAt()==null || !a.replacementBatchKeys().isEmpty()) throw conflict("关联预约尚未有效确认或已被替代,请重新核对批次后发车"); + } + for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) { + var first=group.get(0); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null), + group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true); + } + } + private void requireNoBatch(String customer,long pallet) { + var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList(); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->!Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()) && a.replacementBatchKeys().isEmpty())) + throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); + } + public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); } + public void requireTripBinding(String dispatchNo,long tripId) { + for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { + if(batch.startsWith("DISPATCH:")) continue; + lock(batch); + if(mapper.trips(batch).stream().anyMatch(id->id!=tripId)) throw conflict("同一预约批次已关联其他车次,请先在原预约分批后分别安排车辆"); + } + } + private List privateAllocation(String customer,long dispatch,PhysicalItem item) { + var physical=mapper.physical(item.planPalletId(),customer); + if(physical.isEmpty() || physical.stream().noneMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) return List.of(); + if(physical.stream().anyMatch(p->p.destinationKey()==null || !p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓实物不能混入FBA批次,请先核对托盘目的地"); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList(); + for(var part:physical) { + var count="RECEIVED".equals(item.sourceStage())?part.countedPackages():part.plannedPackages(); + if(count==null || count<=0) throw conflict("客户仓逐票实物数量尚未核实"); + rows.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),"DISPATCH:"+dispatch,customer,AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey(),count)); + } + if(item.packages()==null || rows.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()!=item.packages()) throw conflict("客户仓逐票实物分配与所选托盘箱数不一致"); + return rows; + } + private void lock(String batch) { + if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效"); + try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); } + catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java new file mode 100644 index 000000000..2baf1a182 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning/batches") +public class AppointmentWorkDispatchCandidateController { + private final AppointmentWorkDispatchCandidates service; + public AppointmentWorkDispatchCandidateController(AppointmentWorkDispatchCandidates service) { this.service=service; } + @GetMapping("/{batchKey}/dispatch-candidates") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:overseas-warehouse:inbound-order:list')") + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) { + if(user==null || user.userId()==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw forbidden("仅平台操作人员可读取批次实物"); + return ApiResponse.success(service.read(batchKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java new file mode 100644 index 000000000..18261d271 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Service +public class AppointmentWorkDispatchCandidates { + public record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,Integer packageCount,List allocations,List blockReasons) { } + public record Result(String batchKey,Integer version,List candidates,List blockReasons) { } + private final AppointmentWorkDispatchMapper mapper; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + @Transactional(readOnly=true) + public Result read(String batch) { + var appointment=appointments.batchContext(batch).appointment(); + if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原预约取消或替代,不能选择实物")); + var candidates=mapper.candidates(appointment.id()); + if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批"); + var output=new ArrayList(); + for(var candidate:candidates) { + var reasons=new ArrayList(); var allocations=new ArrayList(); + if(candidate.locked()) reasons.add("托盘已被其他排车或草稿占用"); + var physical=mapper.physical(candidate.planPalletId(),candidate.customerId()); + var refs=AppointmentWorkPhysicalSources.canonical(physical,sources); + Integer packages=0; + for(int i=0;ia.customerId().equals(part.customerId()) && a.sourceKey().equals(ref.sourceKey()) && a.destinationKey().equals(part.destinationKey())).findFirst().orElse(null); + if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } + long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); + if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); + allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count)); + } + if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); } + output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList())); + } + return new Result(batch,appointment.version(),List.copyOf(output),List.of()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java new file mode 100644 index 000000000..c6c0058d9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkDispatchMapper { + record Physical(Long orderId,String customerId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } + record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { } + record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { } + record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { } + @Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List dispatchPhysical(String number); + @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.plan_pallet_id=p.id AND k.status='ACTIVE') locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") + List candidates(long id); + @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,SUM(a.planned_package_count) planned_packages,SUM(a.counted_package_count) counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") + List physical(@Param("pallet") long pallet,@Param("customer") String customer); + @Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code") + List pickedPhysical(@Param("item") long item,@Param("customer") String customer); + @Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE") InboundFbaAppointment lockAppointment(long id); + @Select("SELECT COALESCE(SUM(b.package_count),0) FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE b.batch_key=#{batch} AND b.customer_id=#{customer} AND b.source_key=#{source} AND b.destination_key=#{destination} AND b.dispatch_id<>#{dispatch} AND d.status<>'CANCELLED'") + long allocated(@Param("batch") String batch,@Param("customer") String customer,@Param("source") String source,@Param("destination") String destination,@Param("dispatch") long dispatch); + @Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch}") int remove(long dispatch); + @Insert("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(#{dispatchId},#{itemId},#{palletId},#{batchKey},#{customerId},#{sourceKey},#{destinationKey},#{packages})") + int insert(Bound row); + @Select("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") int count(long id); + @Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch} AND dispatch_item_id=#{item}") int removeItem(@Param("dispatch") long dispatch,@Param("item") long item); + @Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List trips(String batch); + @Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}") + List bindings(String number); + @Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List byDispatch(long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java new file mode 100644 index 000000000..7050a31cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +final class AppointmentWorkPhysicalSources { + private AppointmentWorkPhysicalSources() { } + static Map canonical(List rows,AppointmentSourceService sources) { + var identities=rows.stream().map(p->new AppointmentExternalSourceGateway.OrderRef(p.customerId(),p.orderId())).distinct().toList(); + if(identities.isEmpty()) return Map.of(); + var refs=sources.canonicalOrders(identities); + if(refs.size()!=identities.size()) throw unavailable("逐票来源规范化响应不完整"); + var result=new LinkedHashMap(); + for(int i=0;i refs,AppointmentWorkDispatchMapper.Physical row) { + return refs.get(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java new file mode 100644 index 000000000..42582a945 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFirstlegClient; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Flow; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkReceivingGuard { + private final AppointmentWorkFirstlegClient firstleg; + private final String token; + private final com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes; + public AppointmentWorkReceivingGuard(AppointmentWorkFirstlegClient firstleg,com.baiyue.lastleg.appointmentwork.AppointmentWorkRouteService routes,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.routes=routes; this.token=token; } + public void requireWarehouseFlow(String container) { + if(routes.route(container).flow()==Flow.DIRECT) throw conflict("该柜已确认直送路线,不能进入海外仓收货"); + if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置,无法核对收货路线"); + final com.baiyue.common.core.api.ApiResponse response; + try { response=firstleg.container(token,container); } + catch(RuntimeException error) { throw unavailable("无法核对柜次原业务路线",error); } + if(response==null || !response.successful() || response.data()==null || !container.equals(response.data().containerBookingId())) throw unavailable("柜次原业务路线缺失"); + if(response.data().establishedFlow()==Flow.DIRECT) throw conflict("该柜已有直送卸货事实,不能进入海外仓收货"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java new file mode 100644 index 000000000..c2f68714f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkReceivingMapper { + @Select("SELECT DISTINCT p.* FROM ll_inbound_plan p JOIN ll_inbound_plan_line l ON l.plan_id=p.id JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id WHERE o.source_container_booking_id=#{container} ORDER BY p.id LIMIT 1001") + List plans(String container); + @Select("SELECT * FROM ll_receiving_container_session WHERE source_container_booking_id=#{container} ORDER BY created_at DESC,id DESC LIMIT 1") + ReceivingContainerSession session(String container); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java new file mode 100644 index 000000000..30ac844ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +@Component +public class AppointmentWorkReceivingProvider implements AppointmentWorkFactGateway.BusinessProvider { + private final AppointmentWorkReceivingMapper mapper; + public AppointmentWorkReceivingProvider(AppointmentWorkReceivingMapper mapper) { this.mapper=mapper; } + public boolean supports(Type type,Flow flow) { return type==Type.WAREHOUSE_RECEIPT && flow==Flow.TRANSFER; } + public List read(List requests) { return requests.stream().map(this::read).toList(); } + private Fact read(FactRequest request) { + var plans=mapper.plans(request.containerBookingId()); + if(plans.size()>1000) throw unavailable("柜次接仓计划超过完整读取上限"); + var session=mapper.session(request.containerBookingId()); + var dates=plans.stream().map(p->p.getPlannedArrivalDate()).filter(Objects::nonNull).distinct().sorted().toList(); + var target=dates.isEmpty()?null:new Slot(dates.get(0),null,null,null,null); + boolean complete=session!=null && session.getCompletedAt()!=null && Set.of("COMPLETED","COMPLETED_WITH_DIFFERENCE").contains(session.getStatus()); + var local=complete?session.getCompletedAt():null; + var actual=local==null?null:new Actual(null,local,null); + var execution=complete?ExecutionStatus.COMPLETED:session!=null && session.getReceivingAt()!=null?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING; + var blocks=new ArrayList(); + if(dates.size()>1) blocks.add("同柜入仓计划日期不一致,请在原入仓计划核对"); + var refs=plans.stream().map(p->new BusinessReference("INBOUND_PLAN",p.getId().toString(),p.getPlanNo(),"入仓计划")).toList(); + var permission=plans.isEmpty()?OverseasPermissions.INBOUND_ORDER_CREATE:OverseasPermissions.INBOUND_ORDER_EDIT; + var single=plans.size()==1?plans.get(0):null; + var action=complete?null:new Action(ActionCode.SAVE_PLAN,plans.isEmpty()?"建立接仓计划":"查看并维护接仓计划",request.workItemKey(),new ReceiptTarget(single==null?null:single.getId().toString(),single==null?null:single.getPlanNo(),single==null?null:single.getCustomerId(),request.containerBookingId())); + String fingerprint=digest(plans.stream().map(p->p.getId()+":"+p.getVersion()+":"+p.getStatus()).toList()+":"+(session==null?"":session.getId()+":"+session.getVersion())); + // Arrival-date mutations are authoritative planned changes; first date is baselined by the work observer. + String impact=digest(dates.toString()); + return new Fact(request.workItemKey(),fingerprint,impact,!plans.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET, + execution,target,null,actual,null,plans.size()==1?plans.get(0).getPlanNo():null,null,List.of(OverseasPermissions.INBOUND_ORDER_LIST), + action==null?List.of():List.of(permission),action,List.copyOf(blocks),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),refs,null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java new file mode 100644 index 000000000..2f4a114ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** Full manifest from source quantities and original appointment allocations; owns no quantity ledger. */ +@Component +public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGateway.RequirementProvider { + private final AppointmentSourceService sources; + private final AppointmentCargoVerificationService cargo; + private final InboundFbaAppointmentService appointments; + private final AppointmentWorkSourceMapper sourceMapping; + public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { + this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; + } + public List requirements(ContainerSource container,Flow effectiveFlow) { + var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + if(effectiveFlow==Flow.DIRECT) return original; + if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); + var mapped=sourceMapping.containerSources(container.containerBookingId()); + if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); + var result=new LinkedHashMap(); + var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); + if(privateDispatches.size()>1000) throw unavailable("客户仓排车来源超过完整读取上限"); + for(var requirement:original) { + var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; + var sourceOrders=mapped.stream().filter(s->Objects.equals(s.sourceInboundId(),inbound)).toList(); + if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } + if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } + if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } + var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); + var resolved=sources.resolveBatch(refs); + var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); + var history=appointments.relatedBatchSnapshots(complete); + var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); + // Unknown remaining quantity is still work, never a zero or an absent requirement. + boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); + if(remainder) result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(), + requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),replaced, + complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList())); + for(var batch:active) { + var replacementKeys=new LinkedHashSet<>(replaced); replacementKeys.add(requirement.requirementKey()); + history.stream().filter(a->a.replacementBatchKeys()!=null && a.replacementBatchKeys().contains(batch.batchKey())).map(FbaAppointmentResponse::batchKey).forEach(replacementKeys::add); + var previous=result.get(batch.batchKey()); + if(previous!=null) replacementKeys.addAll(previous.replacesRequirementKeys()); + var references=new ArrayList<>(requirement.references()); + if(previous!=null) references.addAll(previous.references()); + references.add(new BusinessReference("FBA_APPOINTMENT",batch.batchKey(),batch.appointmentNo(),"送仓预约")); + var batchSources=batch.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).distinct().toList(); + result.put(batch.batchKey(),new Requirement(batch.batchKey(),batch.batchKey(),batch.appointmentNo(),"FBA",requirement.originName(), + batch.fbaWarehouseCode(),batch.timezone(),references.stream().distinct().toList(),List.copyOf(replacementKeys),batchSources)); + } + } + return List.copyOf(result.values()); + } + private void customer(Requirement requirement,List orders,List dispatches,Map result) { + if(orders.stream().anyMatch(s->!"PRIVATE".equals(s.destinationType()))) throw conflict("客户仓需求与原订单目的地不一致"); + var related=dispatches.stream().filter(d->orders.stream().anyMatch(o->o.id()==d.orderId() && o.customerId().equals(d.customerId()))).toList(); + var active=related.stream().filter(d->!"CANCELLED".equals(d.status())).toList(); + var cancelled=related.stream().filter(d->"CANCELLED".equals(d.status())).map(d->"DISPATCH:"+d.dispatchId()).distinct().toList(); + boolean remaining=false; + for(var order:orders) { + long allocated=active.stream().filter(d->d.orderId()==order.id() && d.customerId().equals(order.customerId())).mapToLong(AppointmentWorkSourceMapper.DispatchSource::packages).sum(); + if(order.packageCount()!=null && allocated>order.packageCount()) throw conflict("客户仓排车逐票分配超过原货物数量"); + if(order.packageCount()==null || allocatednew SourceReference(o.customerId(),"ORDER:"+o.id(),"CUSTOMER:"+o.id())).toList())); + for(var batch:active.stream().map(d->"DISPATCH:"+d.dispatchId()).distinct().toList()) { + var all=sourceMapping.privateBatch(batch); + if(all.isEmpty() || all.size()>1000 || all.stream().anyMatch(d->"CANCELLED".equals(d.status()))) throw unavailable("客户仓排车批次来源不完整"); + var previous=result.get(batch); var replacements=new LinkedHashSet<>(cancelled);replacements.add(requirement.requirementKey()); + var refs=new ArrayList<>(requirement.references()); + if(previous!=null) { replacements.addAll(previous.replacesRequirementKeys());refs.addAll(previous.references()); } + refs.add(new BusinessReference("DISPATCH",batch,all.get(0).dispatchNo(),"客户仓排车")); + result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); + } + } + private List canonical(List input) { + if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ + try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } + catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } + }).toList(); + var result=new ArrayList(); + if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); + input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); + if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); + return result.stream().distinct().toList(); + } + private static boolean active(FbaAppointmentResponse batch) { + return !Set.of("CANCELLED","FAILED").contains(batch.appointmentStatus()) && (batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java new file mode 100644 index 000000000..ac3503952 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface AppointmentWorkSourceMapper { + record Source(Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { } + @Select("SELECT id,customer_id,source_order_id,source_inbound_id,destination_type,fba_warehouse_code,package_count FROM ll_order WHERE source_container_booking_id=#{container} ORDER BY id LIMIT 1001") + List containerSources(String container); + record DispatchSource(long dispatchId,String dispatchNo,String status,long orderId,String customerId,String sourceKey,String destinationKey,long packages) { } + @Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=CONCAT('DISPATCH:',d.id) AND o.source_container_booking_id=#{container} GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY b.dispatch_id,o.id LIMIT 1001") + List privateDispatches(String container); + @Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=#{batch} AND b.batch_key=CONCAT('DISPATCH:',d.id) GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY o.id LIMIT 1001") + List privateBatch(String batch); +} diff --git a/lastleg-service/src/main/resources/db/appointment-work.sql b/lastleg-service/src/main/resources/db/appointment-work.sql new file mode 100644 index 000000000..66cce1014 --- /dev/null +++ b/lastleg-service/src/main/resources/db/appointment-work.sql @@ -0,0 +1,59 @@ +-- DDL proposal only. The coordinator must integrate the approved baseline; do not execute from this task. +CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation ( + dispatch_id BIGINT NOT NULL, + dispatch_item_id BIGINT NOT NULL, + plan_pallet_id BIGINT NOT NULL, + batch_key VARCHAR(96) NOT NULL, + customer_id VARCHAR(64) NOT NULL, + source_key VARCHAR(160) NOT NULL, + destination_key VARCHAR(160) NOT NULL, + package_count INT NOT NULL, + PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key), + KEY idx_dispatch_appointment_batch(batch_key,customer_id), + KEY idx_dispatch_appointment_dispatch(dispatch_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment ( + entity_key VARCHAR(160) NOT NULL, + state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + impact_version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (entity_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_guard ( + id INT NOT NULL COMMENT '工作事项协调锁,固定1', + scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标', + initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描', + scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本', + scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间', + requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本', + synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本', + scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1); + +CREATE TABLE IF NOT EXISTS ll_appointment_work ( + work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本', + retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作', + state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (work_item_key), + KEY idx_appointment_work_container (container_booking_id,retired) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; + +CREATE TABLE IF NOT EXISTS ll_appointment_work_history ( + id VARCHAR(36) NOT NULL COMMENT '历史记录标识', + work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识', + operator_name VARCHAR(128) NULL COMMENT '操作人显示名称', + action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作', + summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据', + command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空', + request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要', + response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变', + occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_work_command (work_item_key,command_key), + KEY idx_appointment_work_history (work_item_key,occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6990e6844..603964c39 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1667,6 +1667,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码', selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数', + replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联', + repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留', created_by VARCHAR(128) NOT NULL COMMENT '登录操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间', @@ -1686,19 +1689,42 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空', inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码', - package_count INT NULL COMMENT '所选整票预报箱数,未知保持空', + package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒', + cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用', - active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键', - active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key), - UNIQUE KEY uk_ll_fba_active_source (active_source_key), - UNIQUE KEY uk_ll_fba_active_order (active_order_id), + KEY idx_ll_fba_active_source (customer_id,source_key,active), + KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_customer_order (customer_id,order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +CREATE TABLE IF NOT EXISTS ll_appointment_cargo_verification ( + id BIGINT NOT NULL COMMENT '核实记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地', + source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本', + source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空', + version INT NOT NULL COMMENT '同一来源的核实版本,从一递增', + package_count INT NULL COMMENT '独立核实箱数,不回写原订单', + shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + remark VARCHAR(1000) NULL COMMENT '核实说明', + operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人', + operator_name VARCHAR(200) NULL COMMENT '操作人名称快照', + command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键', + request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要', + created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version), + UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), + CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), + CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + CREATE TABLE IF NOT EXISTS ll_fba_appointment_command ( command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要', @@ -4082,3 +4108,65 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily ( KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; + + +-- Appointment work scope, audit and physical batch allocation. +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_appointment_allocation ( + dispatch_id BIGINT NOT NULL, + dispatch_item_id BIGINT NOT NULL, + plan_pallet_id BIGINT NOT NULL, + batch_key VARCHAR(96) NOT NULL, + customer_id VARCHAR(64) NOT NULL, + source_key VARCHAR(160) NOT NULL, + destination_key VARCHAR(160) NOT NULL, + package_count INT NOT NULL, + PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key), + KEY idx_dispatch_appointment_batch(batch_key,customer_id), + KEY idx_dispatch_appointment_dispatch(dispatch_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_commitment ( + entity_key VARCHAR(160) NOT NULL, + state_json LONGTEXT NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + impact_version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (entity_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +CREATE TABLE IF NOT EXISTS ll_appointment_work_guard ( + id INT NOT NULL COMMENT '工作事项协调锁,固定1', + scan_cursor VARCHAR(128) NULL COMMENT '已开船来源同步游标', + initialized BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成首次完整来源扫描', + scope_version VARCHAR(128) NULL COMMENT '完整同步已确认的权威来源集合版本', + scan_scope_version VARCHAR(128) NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + completed_at DATETIME(3) NULL COMMENT '最近完整同步UTC时间', + requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '原域命令在同一事务推进的需求版本', + synchronized_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '完整扫描已消费需求版本', + scan_requirement_version BIGINT NOT NULL DEFAULT 0 COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE WHERE NOT EXISTS(SELECT 1 FROM ll_appointment_work_guard WHERE id=1); + +CREATE TABLE IF NOT EXISTS ll_appointment_work ( + work_item_key VARCHAR(96) NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + container_booking_id VARCHAR(64) NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + version INT NOT NULL DEFAULT 0 COMMENT '工作命令乐观锁版本', + retired BOOLEAN NOT NULL DEFAULT FALSE COMMENT '已替代或已完成路线需求,不计活动工作', + state_json LONGTEXT NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (work_item_key), + KEY idx_appointment_work_container (container_booking_id,retired) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; + +CREATE TABLE IF NOT EXISTS ll_appointment_work_history ( + id VARCHAR(36) NOT NULL COMMENT '历史记录标识', + work_item_key VARCHAR(96) NOT NULL COMMENT '事项稳定键', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人员标识', + operator_name VARCHAR(128) NULL COMMENT '操作人显示名称', + action VARCHAR(48) NOT NULL COMMENT '工作命令或观察动作', + summary VARCHAR(2000) NOT NULL COMMENT '处理或责任变更依据', + command_key VARCHAR(64) NULL COMMENT '请求幂等键,观察记录为空', + request_hash VARCHAR(64) NULL COMMENT '包含操作者的规范请求摘要', + response_json LONGTEXT NULL COMMENT '原命令响应快照,重放不随当前状态改变', + occurred_at DATETIME(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_work_command (work_item_key,command_key), + KEY idx_appointment_work_history (work_item_key,occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; diff --git a/lastleg-service/src/main/resources/openapi/appointment-batches-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-batches-v1.yaml new file mode 100644 index 000000000..e62413a7c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-batches-v1.yaml @@ -0,0 +1,136 @@ +openapi: 3.0.3 +info: + title: 预约货物分批与合并 + version: 1.0.0 + description: 原子重新分配已有未确认批次。保留原来源、客户、柜及历史;不创建车次、库存或外部确认。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/appointment-planning/batches/{batchKey}: + get: + operationId: getFbaAppointmentBatchContext + summary: 按不可变批次键读取分批资料和操作条件 + description: >- + 仅平台,需预约列表及尾程订单列表权限。batchKey为FBA_APPOINTMENT加内部不可变主键,仅供关联,不作为展示编号。 + 返回appointment原快照、sources当前逐来源可用于重新分配的本批数量、allowedActions和blockReasons。 + 原批次箱数未知且已独立核实总量时,本批数量由核实总量减其他活动批次分配量计算;不修改旧快照。 + 已替代、已确认或关联排车时不开放分批合并。原来源或核实事实读取失败明确报错,不以空列表冒充无货。 + allowedActions表示业务资格,前端与FBA预约写权限取交集;实际写入仍在事务内重验。 + parameters: + - {name: batchKey, in: path, required: true, schema: {type: string, pattern: '^FBA_APPOINTMENT:[1-9][0-9]*$'}} + responses: + '200': + description: code=200时data为批次上下文 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + appointment: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'} + sources: + type: array + items: + type: object + properties: + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string} + packageCount: {type: integer, nullable: true, description: 本批分配量或可从独立核实资料确认的本批量} + originalPackages: {type: integer, nullable: true, description: 原订单/登记箱数,独立核实不回写} + sourceVersion: {type: string, nullable: true} + cargoVerificationVersion: {type: integer, nullable: true} + shipmentReferences: {type: array, items: {type: string}} + allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO, SPLIT, MERGE]}} + blockReasons: {type: array, items: {type: string}} + '400': {description: 批次键格式无效} + '403': {description: 非平台或缺少读取权限} + '404': {description: 批次或原来源不存在} + '409': {description: 原来源变化,需重新核实资料} + '503': {description: 原来源权威目录不可用} + /api/platform/v1/last-leg/appointment-planning/appointments/repartition: + post: + operationId: repartitionFbaAppointments + summary: 一批分多批或多批合一批 + description: >- + 仅平台,需platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。 + originals为一条且batches多条表示分批;originals多条且batches一条表示合并,其他组合拒绝。 + 原批次必须同起运仓、同目的仓,尚未正式确认、未被替代且没有有效关联排车。 + 先按业务号有序锁原预约,再锁原BOOKING/订单;版本、来源资格、客户归属及数量均在锁内重新核对。 + 按每个稳定来源分别比较调整前后总量,不能只比较合计箱数或跨客户挪量;逐来源预期版本及核实版本必须与最新资料一致。 + 原批次箱数未知时可采用刚保存的独立核实总量,无需先登记预约确认;仍未知则拒绝。 + 全部旧明细释放、新批次和来源明细建立、替代关系及幂等结果在同一事务内;任一失败全部回滚。 + 原批次仍可详情读取,replacementAppointmentNos和repartitionReason标识替代关系,不能再次编辑或取消;日历和未定时列表只返回当前批次。 + 新批次都是PENDING,仅保存显式计划日期,确认号和确认时段为空;旧柜级提柜、接仓、还空不复制。 + 同键同操作人同请求返回原结果,后续编辑不会改变原重试快照;同键异内容、并发版本或已安排冲突返回409。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Repartition'} + responses: + '200': + description: code=200时data为原批次业务号及新批次快照,按请求batches顺序返回 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + replacedBatchKeys: {type: array, items: {type: string}, description: 被替代的稳定批次键,用于协调原工作需求} + replacedAppointmentNos: {type: array, items: {type: string}} + appointments: {type: array, items: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}} + '400': {description: 分组不合法、客户/来源替换、数量不守恒或跨起讫点} + '403': {description: 非平台或权限不足} + '404': {description: 原批次或货源不存在} + '409': {description: 原版本变化、来源待核实、已有确认或有效排车、幂等内容冲突} + '503': {description: 权威仓库目录或时区暂不可用} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Repartition: + type: object + required: [originals, batches, reason, idempotencyKey] + properties: + originals: + type: array + minItems: 1 + maxItems: 20 + items: + type: object + required: [appointmentNo, version] + properties: + appointmentNo: {type: string, minLength: 1, maxLength: 64} + version: {type: integer, minimum: 0} + batches: + type: array + minItems: 1 + maxItems: 20 + items: + type: object + required: [allocations] + properties: + plannedDate: {type: string, format: date, nullable: true} + remark: {type: string, maxLength: 1000, nullable: true} + allocations: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [customerId, sourceKey, packageCount] + properties: + customerId: {type: string, minLength: 1, maxLength: 128} + sourceKey: {type: string, minLength: 1, maxLength: 160} + packageCount: {type: integer, minimum: 1, description: 本批来自该原来源的箱数;不使用零数量占位行} + expectedSourceVersion: {type: string, maxLength: 160, nullable: true, description: 批次上下文sources的sourceVersion;空值也须一致} + expectedVerificationVersion: {type: integer, minimum: 1, nullable: true, description: 批次上下文sources的cargoVerificationVersion,无独立核实时为空} + reason: {type: string, minLength: 1, maxLength: 1000} + idempotencyKey: {type: string, minLength: 1, maxLength: 64} diff --git a/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml new file mode 100644 index 000000000..c779987f0 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml @@ -0,0 +1,114 @@ +openapi: 3.0.3 +info: + title: 预约货物核实资料 + version: 1.0.0 + description: >- + 按原ORDER或BOOKING货源保存独立核实资料,不修改订单、登记、库存或预约确认状态。 + 未取得预约确认也可保存箱数和各票货件号。核实记录逐版本追加,取消预约不删除资料。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/cargo-verification: + parameters: + - {name: sourceKey, in: path, required: true, schema: {type: string, maxLength: 160}, description: 原稳定货源键;外接货来货前后均使用BOOKING键} + - {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}, description: 服务端核对的原客户归属} + get: + operationId: getAppointmentCargoVerification + summary: 读取本票独立核实资料和分配总量 + description: >- + 仅平台,需platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + source为权威原来源,verification为空表示尚无独立核实记录。 + 原版本、目的地或原箱数变化后sourceChanged为true,可分配量返回null,重新核实前不得采用旧资料。 + allocatedPackages合计当前活动预约明细;未知分配单独计数,不当作零。 + availablePackages为当前有效总量减已分配量,未知、超量或待复核时为空,不虚构可用量。 + allowedActions表示业务资格;前端仍须与当前用户写权限取交集。 + parameters: + - {name: destinationKey, in: query, schema: {type: string, maxLength: 160}, description: 可传选择时的目的地;与原来源不符则拒绝} + responses: + '200': + description: 业务响应,data为核实上下文 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/CargoContext'} + '400': {description: 来源格式或目的地不符} + '403': {description: 非平台身份或权限不足} + '404': {description: 原来源不存在或客户不匹配} + '503': {description: 权威仓库目录不可用} + post: + operationId: saveAppointmentCargoVerification + summary: 独立保存箱数和本票货件或追踪号 + description: >- + 仅平台,需两个读取权限及platform:overseas-warehouse:inbound-order:fba-appointment。 + 操作人取登录身份。原货源锁与预约写命令共用,锁后重读来源和版本;expectedVersion首次为0。 + 原来源已知箱数时,核实量不得超过原来源;任何情况下不得低于活动预约已分配量。 + 原来源箱数未知时允许独立核实总量,原来源仍为空。 + 可以暂缺箱数或货件,保存不会自动确认预约;正式确认仍需完整材料。 + 同来源客户内的同幂等键、同操作人、同请求返回原核实版本,即使此后又有新版本。 + 同键异内容、并发版本冲突或原来源变化返回409;记录与业务写入处于同一事务。 + 后续预约写入采用核实版本及货件快照;单独再次核实不会改写已保存预约快照。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SaveCargoVerification'} + responses: + '200': + description: 保存或重试原命令的核实资料快照 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/CargoVerification'} + '400': {description: 请求字段无效} + '403': {description: 非平台身份或缺少独立写权限} + '404': {description: 原来源不存在或客户不匹配} + '409': {description: 原来源或核实版本变化、幂等冲突、箱数越界或来源不可安排} + '503': {description: 权威仓库目录不可用} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + SaveCargoVerification: + type: object + required: [destinationKey, expectedVersion, shipmentReferences, idempotencyKey] + properties: + destinationKey: {type: string, minLength: 1, maxLength: 160} + expectedSourceVersion: {type: string, maxLength: 160, nullable: true, description: 读取上下文中的原来源版本,空值也须一致} + expectedVersion: {type: integer, minimum: 0} + packageCount: {type: integer, minimum: 1, nullable: true} + shipmentReferences: {type: array, maxItems: 100, items: {type: string, minLength: 1, maxLength: 128}, description: 本票货件号或追踪号,允许暂时空列表;保存时去首尾空格并去重} + remark: {type: string, maxLength: 1000, nullable: true} + idempotencyKey: {type: string, minLength: 1, maxLength: 64} + CargoVerification: + type: object + required: [version, destinationKey, shipmentReferences, verifiedAt] + properties: + version: {type: integer, minimum: 1} + sourceVersion: {type: string, nullable: true} + destinationKey: {type: string} + sourcePackages: {type: integer, nullable: true} + packageCount: {type: integer, nullable: true} + shipmentReferences: {type: array, items: {type: string}} + remark: {type: string, nullable: true} + operatorName: {type: string, nullable: true} + verifiedAt: {type: string, format: date-time, description: UTC时间} + CargoContext: + type: object + properties: + source: {$ref: './appointment-planning-v1.yaml#/components/schemas/PlanningSource'} + verification: + allOf: [{$ref: '#/components/schemas/CargoVerification'}] + nullable: true + allocatedPackages: {type: integer, format: int64} + unknownAllocationCount: {type: integer, format: int64} + effectivePackages: {type: integer, nullable: true, description: 独立核实箱数;没有核实记录时采用原来源箱数} + availablePackages: {type: integer, format: int64, nullable: true} + sourceChanged: {type: boolean} + allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO]}} + blockReasons: {type: array, items: {type: string}} diff --git a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml index d308929f7..f1dbbba23 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml @@ -6,6 +6,37 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/last-leg/appointment-planning/sources/resolve-order: + get: + operationId: resolveOrderAppointmentSource + summary: 从实际订单读取规范原货源及预约覆盖 + description: >- + 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 入仓列表只知道订单时使用此只读入口;服务端保留外接登记BOOKING键,其他来源返回ORDER键,核对客户、订单关联及目的地。 + 前端后续核实和预约命令必须使用返回的source.sourceKey,不将外接来货改写为ORDER别名。 + parameters: + - {name: orderId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: customerId, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + - {name: destinationKey, in: query, schema: {type: string}} + responses: + '200': + description: 真实原来源和当前活动预约覆盖 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + source: {$ref: '#/components/schemas/PlanningSource'} + coverage: {$ref: '#/components/schemas/Coverage'} + '400': {description: 订单、客户或目的地参数无效} + '403': {description: 非平台或缺少任一读取权限} + '404': {description: 当前客户下来源不存在} + '409': {description: 订单与原来源关联变化} + '503': {description: 权威目录不可用} /api/platform/v1/last-leg/appointment-planning/external-candidates: get: operationId: listExternalAppointmentCandidates @@ -52,7 +83,7 @@ paths: 仅平台;同时需要platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。 与入仓明细入口共用同一FBA预约聚合;系统生成AP单号,不创建入仓计划、库存或车次。 PENDING允许日期、时段、外部约号及箱数暂缺。SUCCESS必须有权威目的仓时区、真实货件材料、确定箱数及外部约号和确认时段。 - 实际客户、起运仓、目的仓及原BOOKING由服务端核对;活动来源与已形成订单均唯一占用,取消后释放并保留历史。 + 实际客户、起运仓、目的仓及原BOOKING由服务端核对;锁原来源后按剩余未分配箱数建立新安排,分批总量不能超过独立核实或原来源总量。取消只释放本批数量并保留历史。 命令与主单、明细同事务;同键同操作人同内容重试返回原快照,不受后来编辑影响。异内容或来源占用冲突拒绝。 requestBody: required: true @@ -117,7 +148,7 @@ paths: description: >- 只读核对,不保存预约安排、不占货、不确认预约。仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 最多100票,批量查询货源、实际开船节点和美国仓目录,支持ORDER与已确认承运BOOKING混合;同一订单重复别名拒绝。 - 必须同美国起运仓及同美国FBA目的仓;暂扣、多目的地、已有有效预约或已取消完成货源拒绝;未知箱数保留待核实,不填0。 + 必须同美国起运仓及同美国FBA目的仓;暂扣、多目的地、已分配完或已取消完成货源拒绝;部分分配后仍可选择剩余量。未知箱数保留待核实,不填0。 未建入仓计划及尚未来货的已确认BOOKING不伪造计划、库存或入仓码;正式确认前必须核实箱数。 未接入权威备妥窗口时返回readinessIncomplete及READY_WINDOW_UNVERIFIED,不拿到港/到仓预测代替。 requestBody: @@ -156,6 +187,7 @@ paths: destinationWarehouseCode: {type: string} destinationTimezone: {type: string, nullable: true, description: 当前目的仓主档IANA时区;主档为空时为null,读取失败返回错误,不使用历史预约快照} sources: {type: array, items: {$ref: '#/components/schemas/PlanningSource'}} + cargoVerification: {type: array, items: {$ref: './appointment-cargo-verification-v1.yaml#/components/schemas/CargoContext'}, description: 各原来源当前独立核实及剩余分配量,sources本身仍保留原事实} missingFields: {type: array, items: {type: string}} group: type: object diff --git a/lastleg-service/src/main/resources/openapi/appointment-work-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-work-v1.yaml new file mode 100644 index 000000000..f1c36c1ca --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-work-v1.yaml @@ -0,0 +1,1437 @@ +openapi: "3.0.3" +info: + title: "预约工作事项" + version: "1.0.0" + description: "工作事项只保存需求、路线、责任、跟进及影响基线。提柜/还空、预约、PDA和Fleet计划与执行事实由原业务提供。独立DDL仍需总控集成。" +security: + - + bearerAuth: [] +paths: + /api/platform/v1/last-leg/appointment-work/calendar: + get: + operationId: "appointmentWorkCalendar" + summary: "完整日期区间的工作聚合" + description: "platform:appointment:list并逐事项核验来源读取资格;最大62个当地日期;五个桶互斥;到港参考不计工作量。完整扫描未就绪、scopeVersion变化或任一权威事实失败均拒绝伪造统计。" + parameters: + - + name: "from" + in: "query" + required: true + schema: + type: "string" + format: "date" + - + name: "to" + in: "query" + required: true + schema: + type: "string" + format: "date" + - + name: "flow" + in: "query" + required: false + schema: + "$ref": "#/components/schemas/Flow" + - + name: "types" + in: "query" + required: false + schema: + type: "array" + items: + type: "string" + enum: + - "PICKUP" + - "WAREHOUSE_RECEIPT" + - "DELIVERY" + - "TRANSPORT" + - "EMPTY_RETURN" + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/CalendarResult" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /api/platform/v1/last-leg/appointment-work/summary: + get: + operationId: "appointmentWorkSummary" + summary: "全部活动事项全局统计" + description: "不接收月份/流程/类型条件。集合可重叠;无日期有跟进日仍是未定日期。未知地点提醒按America/Los_Angeles业务默认时区判断并在missingFields说明。" + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/Summary" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /api/platform/v1/last-leg/appointment-work/items: + get: + operationId: "appointmentWorkItems" + summary: "工作明细" + description: "ALL不传date时排除已完成;传date时包含当日已完成。共享车次按稳定车次键仅一项,containers中任一柜均可命中。" + parameters: + - + name: "view" + in: "query" + required: false + schema: + type: "string" + enum: + - "ALL" + - "FOLLOW_TODAY" + - "OVERDUE" + - "UNDATED" + - "REVIEW" + default: "ALL" + - + name: "page" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + default: 1 + - + name: "size" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + maximum: 100 + default: 20 + - + name: "date" + in: "query" + required: false + schema: + type: "string" + format: "date" + - + name: "flow" + in: "query" + required: false + schema: + "$ref": "#/components/schemas/Flow" + - + name: "types" + in: "query" + required: false + schema: + type: "array" + items: + "$ref": "#/components/schemas/Type" + - + name: "containerBookingId" + in: "query" + required: false + schema: + type: "string" + - + name: "bucket" + in: "query" + required: false + schema: + type: "string" + enum: + - "PENDING" + - "CONFIRMED" + - "NO_BOOKING_REQUIRED" + - "REVIEW" + - "COMPLETED" + - + name: "keyword" + in: "query" + required: false + schema: + type: "string" + maxLength: 128 + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/Page" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /api/platform/v1/last-leg/appointment-work/items/{workItemKey}: + parameters: + - + name: "workItemKey" + in: "path" + required: true + schema: + type: "string" + get: + operationId: "appointmentWorkDetail" + summary: "事项、业务引用、关联与历史" + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/WorkDetail" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /api/platform/v1/last-leg/appointment-work/items/{workItemKey}/commands: + parameters: + - + name: "workItemKey" + in: "path" + required: true + schema: + type: "string" + post: + operationId: "appointmentWorkCommand" + summary: "执行事项自有数据命令" + description: "SAVE_FOLLOWUP/RESOLVE_REVIEW要求platform:appointment:followup;其他命令要求platform:appointment:manage-work,同时核验list与来源资格。USER本人接手;外部责任方须运营记录明确反馈。只确认责任,不写实际还空。事务幂等键按事项唯一,同操作者同内容重放原响应;未知字段拒绝;不支持标为完成。" + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/CommandRequest" + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/CommandResult" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /internal/lastleg/appointment-work/synchronize: + post: + operationId: "synchronizeAppointmentWork" + summary: "服务端分页同步完整已开船事项" + description: "仅X-Internal-Service-Token,不经公网Gateway。由后台编排分页调用;GET不写库。afterContainerBookingId为空开始完整扫描,逐页回传nextCursor直到complete。跨页scopeVersion漂移拒绝;首次完整扫描前查询拒绝零值。未出现在新候选页的旧未还空工作保留。" + security: + - + internalToken: [] + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/SyncRequest" + responses: + 200: + description: "code为字符串200时成功;data为业务结果" + content: + application/json: + schema: + type: "object" + additionalProperties: false + required: + - "code" + - "data" + properties: + code: + type: "string" + message: + type: "string" + nullable: true + data: + "$ref": "#/components/schemas/SyncResult" + 400: + description: "参数、时区或判别式payload不合法" + 403: + description: "非平台或缺少关联来源/命令权限" + 404: + description: "事项不存在" + 409: + description: "版本、来源集合、影响版本或并发冲突;必须刷新" + 503: + description: "权威来源/原域适配/目录未就绪;不得显示零值或假成功" + /internal/lastleg/appointment-work/containers/{id}/route: + get: + operationId: readAppointmentWorkContainerRoute + summary: 读取本地已确认路线,不锁工作守卫或回调Firstleg + security: [{internalToken: []}] + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + responses: + '200': + description: ApiResponse包装的本地持久路线 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: + type: object + required: [flow] + properties: + flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]} + '403': {description: 内部身份无效} + '409': {description: 当前有效事项路线冲突} + /api/platform/v1/last-leg/appointment-planning/batches/{batchKey}/dispatch-candidates: + get: + operationId: appointmentWorkBatchDispatchCandidates + summary: 按不可变FBA预约批次读取逐票实物排车候选 + description: 客户仓使用原排车单批次键DISPATCH:,逐票实物由原排车事务生成;按工作项batchNo进入原排车单并关联车次,不调用本FBA候选接口。平台主体且同时具有platform:appointment:list、platform:overseas-warehouse:last-mile-order:list、platform:overseas-warehouse:inbound-order:list。候选不足不补造数量;超过500托拒绝截断。写入沿用dispatches/pre-arrange及drafts/pre-arrange的batchAllocations,跨客户分别生成排车单后绑定同一车次。 + security: [{bearerAuth: []}] + parameters: + - {name: batchKey, in: path, required: true, schema: {type: string}} + responses: + '200': + description: ApiResponse包装的完整候选及阻断原因 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/BatchDispatchCandidates'} + '403': {description: 非平台主体或无来源读取权限} + '409': {description: 批次或实物范围不能完整读取} + '503': {description: 原来源目录不可用} +components: + securitySchemes: + bearerAuth: + type: "http" + scheme: "bearer" + bearerFormat: "JWT" + internalToken: + type: "apiKey" + in: "header" + name: "X-Internal-Service-Token" + schemas: + Flow: + type: "string" + enum: + - "TRANSFER" + - "DIRECT" + - "UNKNOWN" + Type: + type: "string" + enum: + - "ROUTE" + - "PICKUP" + - "WAREHOUSE_RECEIPT" + - "DELIVERY" + - "TRANSPORT" + - "EMPTY_RETURN" + Command: + type: "string" + enum: + - "CONFIRM_ROUTE" + - "SAVE_FOLLOWUP" + - "RESOLVE_REVIEW" + - "SAVE_REQUIREMENT" + - "HANDOFF_RETURN" + - "ACCEPT_RETURN" + ActionCode: + type: "string" + enum: + - "VIEW_DETAIL" + - "SAVE_PLAN" + - "REQUEST_BOOKING" + - "RECORD_CONFIRMATION" + - "CHANGE_BOOKING" + - "CANCEL_BOOKING" + - "VERIFY_CARGO" + - "EDIT_BATCH" + - "ARRANGE_TRANSPORT" + - "RECORD_EXECUTION" + - "CONFIRM_ROUTE" + - "SAVE_FOLLOWUP" + - "RESOLVE_REVIEW" + - "SAVE_REQUIREMENT" + - "HANDOFF_RETURN" + - "ACCEPT_RETURN" + - "VIEW_HISTORY" + Slot: + type: "object" + additionalProperties: false + required: + - "date" + - "startTime" + - "endDate" + - "endTime" + - "timezone" + properties: + date: + type: "string" + format: "date" + startTime: + type: "string" + pattern: "^\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,9})?)?$" + nullable: true + endDate: + type: "string" + format: "date" + nullable: true + description: "与endTime同时为空或同时填写;不推断次日" + endTime: + type: "string" + pattern: "^\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,9})?)?$" + nullable: true + timezone: + type: "string" + nullable: true + description: "原业务缺失时区可为空并标SOURCE_TIMEZONE;SAVE_REQUIREMENT写入仍须有效IANA时区" + Actual: + type: "object" + additionalProperties: false + description: "occurredAt与localOccurredAt至少一个非空;未知时区按原始当地日期展示,不推断UTC" + required: + - "occurredAt" + - "localOccurredAt" + - "timezone" + properties: + occurredAt: + type: "string" + format: "date-time" + nullable: true + localOccurredAt: + type: "string" + description: "无偏移的原始当地日期时间" + nullable: true + timezone: + type: "string" + nullable: true + Owner: + type: "object" + additionalProperties: false + required: + - "id" + - "name" + properties: + id: + type: "string" + name: + type: "string" + ContainerReference: + type: "object" + additionalProperties: false + required: + - "containerBookingId" + - "bookingNo" + - "containerNo" + properties: + containerBookingId: + type: "string" + bookingNo: + type: "string" + nullable: true + containerNo: + type: "string" + nullable: true + SourceReference: + type: "object" + additionalProperties: false + required: + - "customerId" + - "sourceKey" + - "destinationKey" + properties: + customerId: + type: "string" + sourceKey: + type: "string" + pattern: "^(ORDER|BOOKING):.+$" + destinationKey: + type: "string" + nullable: true + BusinessReference: + type: "object" + additionalProperties: false + required: + - "kind" + - "key" + - "businessNo" + - "label" + properties: + kind: + type: "string" + key: + type: "string" + businessNo: + type: "string" + nullable: true + label: + type: "string" + Responsibility: + type: "object" + additionalProperties: false + required: + - "kind" + - "id" + - "name" + properties: + kind: + type: "string" + enum: + - "USER" + - "SUPPLIER" + - "EXTERNAL" + id: + type: "string" + nullable: true + name: + type: "string" + minLength: 1 + maxLength: 200 + WorkTarget: + type: "object" + additionalProperties: false + required: + - "kind" + - "command" + properties: + kind: + type: "string" + enum: + - "WORK_COMMAND" + command: + "$ref": "#/components/schemas/Command" + CargoTarget: + type: "object" + additionalProperties: false + required: + - "kind" + - "source" + properties: + kind: + type: "string" + enum: + - "CARGO_VERIFICATION" + source: + "$ref": "#/components/schemas/SourceReference" + AppointmentTarget: + type: "object" + additionalProperties: false + required: + - "kind" + - "appointmentId" + - "batchKey" + - "sources" + properties: + kind: + type: "string" + enum: + - "FBA_APPOINTMENT" + appointmentId: + type: "string" + nullable: true + batchKey: + type: "string" + nullable: true + sources: + type: "array" + items: + "$ref": "#/components/schemas/SourceReference" + ContainerTarget: + type: "object" + additionalProperties: false + required: + - "kind" + - "containerBookingId" + properties: + kind: + type: "string" + enum: + - "FIRSTLEG_CONTAINER" + containerBookingId: + type: "string" + TripTarget: + type: "object" + description: FLEET_TRIP动作。tripId存在时维护原车次,业务车次号取工作项businessNo。未绑定车次的客户仓批次以DISPATCH:<原排车单不可变ID>放入batchKeys,原排车业务号取工作项batchNo;直接进入该原排车单关联车次,不能解析为FBA预约或再建立排车。FBA批次仍通过实物候选及batchAllocations安排。 + additionalProperties: false + required: + - "kind" + - "tripId" + - "batchKeys" + properties: + kind: + type: "string" + enum: + - "FLEET_TRIP" + tripId: + type: "string" + nullable: true + batchKeys: + type: "array" + items: + type: "string" + ReceiptTarget: + type: "object" + additionalProperties: false + required: + - "kind" + - "inboundPlanId" + - "inboundPlanNo" + - "customerId" + - "containerBookingId" + properties: + kind: + type: "string" + enum: + - "WAREHOUSE_RECEIPT" + inboundPlanId: + type: "string" + nullable: true + inboundPlanNo: + type: "string" + nullable: true + customerId: + type: "string" + nullable: true + containerBookingId: + type: "string" + ReadTarget: + type: "object" + description: 原业务只读入口。客户仓派送使用DELIVERY引用(key与businessNo均为派送业务号);DISPATCH引用的key为DISPATCH:<原排车单ID>,businessNo为原排车单号。仅供导航,不代表已经执行写入动作。 + additionalProperties: false + required: + - "kind" + - "references" + properties: + kind: + type: "string" + enum: + - "READ_ONLY" + references: + type: "array" + items: + "$ref": "#/components/schemas/BusinessReference" + ActionTarget: + oneOf: + - + "$ref": "#/components/schemas/WorkTarget" + - + "$ref": "#/components/schemas/CargoTarget" + - + "$ref": "#/components/schemas/AppointmentTarget" + - + "$ref": "#/components/schemas/ContainerTarget" + - + "$ref": "#/components/schemas/TripTarget" + - + "$ref": "#/components/schemas/ReceiptTarget" + - + "$ref": "#/components/schemas/ReadTarget" + discriminator: + propertyName: "kind" + mapping: + WORK_COMMAND: "#/components/schemas/WorkTarget" + CARGO_VERIFICATION: "#/components/schemas/CargoTarget" + FBA_APPOINTMENT: "#/components/schemas/AppointmentTarget" + FIRSTLEG_CONTAINER: "#/components/schemas/ContainerTarget" + FLEET_TRIP: "#/components/schemas/TripTarget" + WAREHOUSE_RECEIPT: "#/components/schemas/ReceiptTarget" + READ_ONLY: "#/components/schemas/ReadTarget" + Action: + type: "object" + additionalProperties: false + required: + - "code" + - "label" + - "targetWorkItemKey" + - "target" + properties: + code: + "$ref": "#/components/schemas/ActionCode" + label: + type: "string" + targetWorkItemKey: + type: "string" + nullable: true + target: + "$ref": "#/components/schemas/ActionTarget" + WorkItem: + type: "object" + additionalProperties: false + required: + - "workItemKey" + - "version" + - "type" + - "flow" + - "title" + - "businessNo" + - "containers" + - "requirementKey" + - "batchKey" + - "batchNo" + - "originName" + - "destinationName" + - "planStatus" + - "executionStatus" + - "reviewRequired" + - "target" + - "confirmed" + - "actual" + - "calendarDate" + - "dateBasis" + - "timezone" + - "owner" + - "followupDate" + - "overdueReasons" + - "allowedActions" + - "nextAction" + - "blockReasons" + - "missingFields" + properties: + workItemKey: + type: "string" + version: + type: "integer" + minimum: 0 + type: + "$ref": "#/components/schemas/Type" + flow: + "$ref": "#/components/schemas/Flow" + title: + type: "string" + businessNo: + type: "string" + nullable: true + containers: + type: "array" + items: + "$ref": "#/components/schemas/ContainerReference" + requirementKey: + type: "string" + nullable: true + batchKey: + type: "string" + nullable: true + batchNo: + type: "string" + nullable: true + originName: + type: "string" + nullable: true + destinationName: + type: "string" + nullable: true + planStatus: + type: "string" + enum: + - "UNPLANNED" + - "TARGET_SET" + - "AWAITING_REPLY" + - "CONFIRMED" + - "NOT_REQUIRED" + executionStatus: + type: "string" + enum: + - "PENDING" + - "IN_PROGRESS" + - "COMPLETED" + reviewRequired: + type: "boolean" + target: + "$ref": "#/components/schemas/Slot" + nullable: true + confirmed: + "$ref": "#/components/schemas/Slot" + nullable: true + actual: + "$ref": "#/components/schemas/Actual" + nullable: true + calendarDate: + type: "string" + format: "date" + nullable: true + dateBasis: + type: "string" + enum: + - "ACTUAL" + - "CONFIRMED" + - "TARGET" + - "NONE" + timezone: + type: "string" + nullable: true + owner: + "$ref": "#/components/schemas/Owner" + nullable: true + followupDate: + type: "string" + format: "date" + nullable: true + overdueReasons: + type: "array" + items: + type: "string" + allowedActions: + type: "array" + items: + "$ref": "#/components/schemas/Action" + nextAction: + "$ref": "#/components/schemas/Action" + nullable: true + blockReasons: + type: "array" + items: + type: "string" + missingFields: + type: "array" + items: + type: "string" + Review: + type: "object" + additionalProperties: false + required: + - "version" + - "reasons" + properties: + version: + type: "integer" + minimum: 0 + reasons: + type: "array" + items: + type: "string" + RequirementDetails: + type: "object" + additionalProperties: false + required: + - "bookingRequired" + - "receivingRequirements" + - "reason" + properties: + bookingRequired: + type: "boolean" + receivingRequirements: + type: "string" + reason: + type: "string" + nullable: true + ReturnHandoff: + type: "object" + additionalProperties: false + required: + - "version" + - "from" + - "to" + - "status" + - "reason" + properties: + version: + type: "integer" + minimum: 0 + from: + "$ref": "#/components/schemas/Responsibility" + to: + "$ref": "#/components/schemas/Responsibility" + status: + type: "string" + enum: + - "PENDING_ACCEPTANCE" + - "ACCEPTED" + reason: + type: "string" + HistoryEntry: + type: "object" + additionalProperties: false + required: + - "occurredAt" + - "operatorName" + - "action" + - "summary" + properties: + occurredAt: + type: "string" + format: "date-time" + operatorName: + type: "string" + nullable: true + action: + type: "string" + summary: + type: "string" + WorkDetail: + type: "object" + additionalProperties: false + required: + - "item" + - "relatedItems" + - "references" + - "history" + - "review" + - "requirement" + - "returnHandoff" + properties: + item: + "$ref": "#/components/schemas/WorkItem" + relatedItems: + type: "array" + items: + "$ref": "#/components/schemas/WorkItem" + references: + type: "array" + items: + "$ref": "#/components/schemas/BusinessReference" + history: + type: "array" + items: + "$ref": "#/components/schemas/HistoryEntry" + review: + "$ref": "#/components/schemas/Review" + nullable: true + requirement: + "$ref": "#/components/schemas/RequirementDetails" + nullable: true + returnHandoff: + "$ref": "#/components/schemas/ReturnHandoff" + nullable: true + Summary: + type: "object" + additionalProperties: false + required: + - "all" + - "followToday" + - "overdue" + - "undated" + - "review" + properties: + all: + type: "integer" + minimum: 0 + followToday: + type: "integer" + minimum: 0 + overdue: + type: "integer" + minimum: 0 + undated: + type: "integer" + minimum: 0 + review: + type: "integer" + minimum: 0 + BucketCounts: + type: "object" + additionalProperties: false + required: + - "pending" + - "confirmed" + - "noBookingRequired" + - "review" + - "completed" + properties: + pending: + type: "integer" + minimum: 0 + confirmed: + type: "integer" + minimum: 0 + noBookingRequired: + type: "integer" + minimum: 0 + review: + type: "integer" + minimum: 0 + completed: + type: "integer" + minimum: 0 + DayAggregate: + type: "object" + additionalProperties: false + required: + - "date" + - "total" + - "typeCounts" + - "counts" + properties: + date: + type: "string" + format: "date" + total: + type: "integer" + minimum: 0 + typeCounts: + type: "object" + additionalProperties: + type: "integer" + minimum: 0 + counts: + "$ref": "#/components/schemas/BucketCounts" + CalendarResult: + type: "object" + additionalProperties: false + required: + - "from" + - "to" + - "total" + - "counts" + - "days" + properties: + from: + type: "string" + format: "date" + to: + type: "string" + format: "date" + total: + type: "integer" + minimum: 0 + counts: + "$ref": "#/components/schemas/BucketCounts" + days: + type: "array" + items: + "$ref": "#/components/schemas/DayAggregate" + Page: + type: "object" + additionalProperties: false + required: + - "records" + - "page" + - "size" + - "total" + - "pages" + properties: + records: + type: "array" + items: + "$ref": "#/components/schemas/WorkItem" + page: + type: "integer" + minimum: 1 + size: + type: "integer" + minimum: 1 + maximum: 100 + total: + type: "integer" + minimum: 0 + pages: + type: "integer" + minimum: 0 + CommandResult: + type: "object" + additionalProperties: false + required: + - "item" + - "affectedWorkItemKeys" + properties: + item: + "$ref": "#/components/schemas/WorkItem" + affectedWorkItemKeys: + type: "array" + items: + type: "string" + SyncRequest: + type: "object" + additionalProperties: false + required: + - "size" + properties: + afterContainerBookingId: + type: "string" + nullable: true + maxLength: 128 + size: + type: "integer" + minimum: 1 + maximum: 100 + SyncResult: + type: "object" + additionalProperties: false + required: + - "sources" + - "created" + - "nextCursor" + - "complete" + properties: + sources: + type: "integer" + minimum: 0 + created: + type: "integer" + minimum: 0 + nextCursor: + type: "string" + nullable: true + complete: + type: "boolean" + CONFIRM_ROUTERequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "CONFIRM_ROUTE" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "flow" + properties: + flow: + type: "string" + enum: + - "TRANSFER" + - "DIRECT" + reason: + type: "string" + nullable: true + maxLength: 1000 + SAVE_FOLLOWUPRequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "SAVE_FOLLOWUP" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "ownerId" + - "followupDate" + - "note" + properties: + ownerId: + type: "string" + minLength: 1 + maxLength: 64 + followupDate: + type: "string" + format: "date" + nullable: true + note: + type: "string" + minLength: 1 + maxLength: 1000 + RESOLVE_REVIEWRequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "RESOLVE_REVIEW" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "reviewVersion" + - "note" + properties: + reviewVersion: + type: "integer" + minimum: 0 + note: + type: "string" + minLength: 1 + maxLength: 1000 + SAVE_REQUIREMENTRequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "SAVE_REQUIREMENT" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "target" + - "bookingRequired" + - "receivingRequirements" + - "reason" + properties: + target: + "$ref": "#/components/schemas/Slot" + nullable: true + bookingRequired: + type: "boolean" + receivingRequirements: + type: "string" + nullable: true + maxLength: 1000 + reason: + type: "string" + minLength: 1 + maxLength: 1000 + HANDOFF_RETURNRequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "HANDOFF_RETURN" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "recipient" + - "reason" + properties: + recipient: + "$ref": "#/components/schemas/Responsibility" + reason: + type: "string" + minLength: 1 + maxLength: 1000 + ACCEPT_RETURNRequest: + type: "object" + additionalProperties: false + required: + - "command" + - "expectedVersion" + - "idempotencyKey" + - "payload" + properties: + command: + type: "string" + enum: + - "ACCEPT_RETURN" + expectedVersion: + type: "integer" + minimum: 0 + idempotencyKey: + type: "string" + minLength: 1 + maxLength: 64 + payload: + type: "object" + additionalProperties: false + required: + - "handoffVersion" + - "note" + properties: + handoffVersion: + type: "integer" + minimum: 0 + note: + type: "string" + nullable: true + maxLength: 1000 + CommandRequest: + description: SAVE_REQUIREMENT新增目标须为分钟精度且有IANA时区;原业务Slot允许保留秒、纳秒及未知时区。 + oneOf: + - + "$ref": "#/components/schemas/CONFIRM_ROUTERequest" + - + "$ref": "#/components/schemas/SAVE_FOLLOWUPRequest" + - + "$ref": "#/components/schemas/RESOLVE_REVIEWRequest" + - + "$ref": "#/components/schemas/SAVE_REQUIREMENTRequest" + - + "$ref": "#/components/schemas/HANDOFF_RETURNRequest" + - + "$ref": "#/components/schemas/ACCEPT_RETURNRequest" + discriminator: + propertyName: "command" + mapping: + CONFIRM_ROUTE: "#/components/schemas/CONFIRM_ROUTERequest" + SAVE_FOLLOWUP: "#/components/schemas/SAVE_FOLLOWUPRequest" + RESOLVE_REVIEW: "#/components/schemas/RESOLVE_REVIEWRequest" + SAVE_REQUIREMENT: "#/components/schemas/SAVE_REQUIREMENTRequest" + HANDOFF_RETURN: "#/components/schemas/HANDOFF_RETURNRequest" + ACCEPT_RETURN: "#/components/schemas/ACCEPT_RETURNRequest" + DispatchBatchAllocation: + type: object + additionalProperties: false + required: [planPalletId, batchKey, customerId, sourceKey, destinationKey, packageCount] + properties: + planPalletId: {type: integer, format: int64} + batchKey: {type: string, pattern: '^FBA_APPOINTMENT:[1-9][0-9]*$'} + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string} + packageCount: {type: integer, minimum: 1} + BatchDispatchCandidates: + type: object + required: [batchKey, version, candidates, blockReasons] + properties: + batchKey: {type: string} + version: {type: integer} + blockReasons: {type: array, items: {type: string}} + candidates: + type: array + items: + type: object + required: [planPalletId, palletNo, planId, planNo, customerId, sourceStage, packageCount, allocations, blockReasons] + properties: + planPalletId: {type: integer, format: int64} + palletNo: {type: string} + planId: {type: integer, format: int64} + planNo: {type: string} + customerId: {type: string} + sourceStage: {type: string, enum: [PLAN, RECEIVED]} + packageCount: {type: integer, nullable: true} + allocations: {type: array, items: {$ref: '#/components/schemas/DispatchBatchAllocation'}} + blockReasons: {type: array, items: {type: string}} diff --git a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml index 58283d672..b5d515763 100644 --- a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml @@ -229,6 +229,10 @@ components: properties: id: {type: string, description: 内部标识,不作为展示编号} appointmentNo: {type: string, description: 系统生成AP业务单号} + batchKey: {type: string, description: FBA_APPOINTMENT加不可变主键,用于工作事项关联,不展示内部键} + replacementBatchKeys: {type: array, items: {type: string}, description: 分批/合并后的稳定批次键;旧批次历史保留} + replacementAppointmentNos: {type: array, items: {type: string}, description: 替代批次业务号;非空时原批次只读} + repartitionReason: {type: string, nullable: true} confirmationNo: {type: string, nullable: true, description: 对方正式外部确认号} plannedDate: {type: string, format: date, nullable: true} plannedAt: {type: string, nullable: true} @@ -255,8 +259,11 @@ components: id: {type: string} customerId: {type: string, description: 本票货物客户归属,不在预约主单指定客户} sourceKey: {type: string} + destinationKey: {type: string, description: 本票目的地FBA加仓码,与原来源归属一同保留} sourceVersion: {type: string, nullable: true} orderId: {type: string, nullable: true} inboundCode: {type: string, nullable: true} packageCount: {type: integer, nullable: true} + cargoVerificationVersion: {type: integer, nullable: true, description: 本次采用的独立核实版本,后续单独核实不改写此快照} + shipmentReferences: {type: array, items: {type: string}, description: 本次采用的独立核实货件或追踪号快照,无核实资料时为空列表} fbaWarehouseCode: {type: string} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 9af723579..2a9809de9 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -29,12 +29,12 @@ security: paths: /api/platform/v1/last-leg/fleet/trips/calendar: get: - summary: 读取美国车次计划日历来源 + summary: 读取美国车次用车日历来源 description: >- - 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,按计划发车时间读取半开区间,最多44天; + 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天; 该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。 最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。 - 一车一条,保留取消及完成状态;记录只表示用车计划,不表示收货预约已确认。 + 一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。 已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。 一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。 parameters: diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 1fc755f60..f77bb7df9 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -512,7 +512,7 @@ paths: - { name: destinationWarehouseCode, in: query, schema: { type: string } } - { name: warehouse, in: query, schema: { type: string } } - { name: fbaWarehouseCode, in: query, schema: { type: string } } - - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } } + - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED, MULTIPLE] } } - { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } @@ -1372,7 +1372,7 @@ paths: - { name: status, in: query, schema: { type: string } } - { name: overseasWarehouseId, in: query, schema: { type: string } } - { name: destinationWarehouseCode, in: query, schema: { type: string } } - - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } } + - { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED, MULTIPLE] } } - { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } @@ -2698,6 +2698,7 @@ components: draftContainerId: { type: integer, format: int64 } planPalletIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } returnReceiptIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } + batchAllocations: { type: array, maxItems: 1000, items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' } } SmartDispatchDraftGroupResult: type: object required: [draftContainerId, dispatch] @@ -2708,6 +2709,11 @@ components: type: object description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 properties: + batchAllocations: + type: array + maxItems: 1000 + description: 已有预约批次的FBA实物必须逐票绑定,整托总量或可变预约号不能替代;发车再次校验实物与原预约。 + items: { $ref: './appointment-work-v1.yaml#/components/schemas/DispatchBatchAllocation' } planPalletIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string } } returnReceiptIds: { type: array, maxItems: 500, uniqueItems: true, items: { type: string }, description: 原订单实际退回入账来源ID } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapperTest.java index 2b5c4c2b1..6809ab544 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapperTest.java @@ -17,11 +17,11 @@ class AppointmentBatchPreviewMapperTest { s.execute("CREATE TABLE ll_order(id BIGINT, customer_id VARCHAR(32),detained BOOLEAN,fba_warehouse_code VARCHAR(32))"); s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(32),destination_warehouse_code VARCHAR(32))"); s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR(32))"); - s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT,active BOOLEAN)"); s.execute("INSERT INTO ll_order VALUES(1,'A',FALSE,'ONT8'),(2,'B',TRUE,'ONT8'),(3,'C',FALSE,'ONT8')"); s.execute("INSERT INTO ll_order_detail VALUES(1,'OTHER','LAX9'),(2,'B','ONT8'),(3,'C','LAX9')"); s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(10,'CANCELLED'),(11,'SUCCESS')"); - s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10),(1,'OTHER',11),(3,'C',11)"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10,TRUE),(1,'OTHER',11,TRUE),(3,'C',11,TRUE)"); } var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentBatchPreviewMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java index 8f9ff1197..8433d7474 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java @@ -7,6 +7,21 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentBatchPreviewServiceTest { + private static AppointmentCargoVerificationService verification() { + var service=mock(AppointmentCargoVerificationService.class); + when(service.inspect(any())).thenAnswer(call->{AppointmentSourceService.Source source=call.getArgument(0);return new AppointmentCargoVerificationService.Context(source,null,0,0,source.packages(),source.packages()==null?null:source.packages().longValue(),false,List.of("VERIFY_CARGO"),List.of());}); + return service; + } + @Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() { + var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);var verification=verification(); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));var source=source("ORDER:1","A",1L,"100","FBA:ONT8"); + when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true))); + when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,6,0,10,4L,false,List.of("VERIFY_CARGO"),List.of())); + var service=new AppointmentBatchPreviewService(sources,mapper,verification); + assertEquals(4,service.preview(refs).group().packages()); + when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,10,0,10,0L,false,List.of("VERIFY_CARGO"),List.of())); + assertThrows(ResponseStatusException.class,()->service.preview(refs)); + } private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) { return new AppointmentSourceService.Source(key,customer,orderId,null,null,null,origin,"美国仓",destination,destination.substring(4),10,"1",true,List.of()); } @@ -16,7 +31,7 @@ class AppointmentBatchPreviewServiceTest { when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8"),source("BOOKING:B-1","B",null,"100","FBA:ONT8"))); when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false))); when(sources.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles"); - var preview=new AppointmentBatchPreviewService(sources,mapper).preview(refs); + var preview=new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs); assertEquals("America/Los_Angeles",preview.destinationTimezone()); assertEquals(2,preview.group().customerCount());assertEquals(20,preview.group().packages()); assertTrue(preview.group().readinessIncomplete());assertNull(preview.group().readyFrom()); @@ -28,9 +43,9 @@ class AppointmentBatchPreviewServiceTest { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class); var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8"))); - var service=new AppointmentBatchPreviewService(sources,mapper); + var service=new AppointmentBatchPreviewService(sources,mapper,verification()); for(var flags:List.of(List.of(),List.of(new AppointmentBatchPreviewMapper.Flags(1L,true,false,false)), - List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)),List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)))) { + List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)))) { when(mapper.flags(List.of(1L))).thenReturn(flags); assertEquals(400,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value()); } @@ -39,14 +54,14 @@ class AppointmentBatchPreviewServiceTest { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class); var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","BOOKING:B","FBA:ONT8")); when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8"),source("BOOKING:B","B",null,"200","FBA:ONT8"))); - assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper).preview(refs)); + assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs)); verifyNoInteractions(mapper); } @Test void absentTimezoneStaysUnknownAndLookupFailureIsNotMasked() { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class); var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8")); when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8"))); - var service=new AppointmentBatchPreviewService(sources,mapper); + var service=new AppointmentBatchPreviewService(sources,mapper,verification()); assertNull(service.preview(refs).destinationTimezone()); when(sources.destinationTimezone("ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"lookup failed")); assertEquals(503,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java index 7f7f18b11..5c615046f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java @@ -17,7 +17,7 @@ class AppointmentCargoMapperTest { s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)"); s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)"); s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)"); - s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT,active BOOLEAN)"); s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)"); s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)"); s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)"); @@ -33,7 +33,7 @@ class AppointmentCargoMapperTest { try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')"); s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')"); - s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20,TRUE)"); } session.clearCache(); var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java index ee46c5649..de7061be3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java @@ -24,6 +24,11 @@ class AppointmentCoverageMapperTest { s.execute("INSERT INTO ll_warehouse_transfer_order VALUES(30,3,'C','FBA','ACY2','SHIP-C',NULL)"); s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'AP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-1','America/Los_Angeles'),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-2','America/Los_Angeles')"); s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A','ORDER:1','ORDER:1',20,'ONT8',TRUE),(2,1000,2,'B','ORDER:2','ORDER:1',8,'ONT8',TRUE),(3,2000,1,'A','ORDER:1','ORDER:1',20,'ONT8',FALSE),(4,1000,NULL,'D','BOOKING:B-1','BOOKING:1',NULL,'ONT8',TRUE)"); + s.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD cargo_verification_version INT"); + String baseline;try(var input=getClass().getResourceAsStream("/db/lastleg.sql")){baseline=new String(java.util.Objects.requireNonNull(input).readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);} + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_cargo_verification ("); + s.execute(baseline.substring(start,baseline.indexOf(';',start)+1)); + s.execute("INSERT INTO ll_appointment_cargo_verification(id,customer_id,source_key,destination_key,version,package_count,shipment_references_json,operator_id,command_key,request_hash,created_at) VALUES(1,'A','ORDER:1','FBA:ONT8',1,19,'[]','operator','one',REPEAT('a',64),CURRENT_TIMESTAMP),(2,'A','ORDER:1','FBA:ONT8',2,20,'[\"FBA-A\"]','operator','two',REPEAT('b',64),CURRENT_TIMESTAMP),(3,'B','ORDER:1','FBA:ONT8',1,8,'[]','operator','one',REPEAT('c',64),CURRENT_TIMESTAMP)"); } var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentCoverageMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { @@ -35,6 +40,8 @@ class AppointmentCoverageMapperTest { var external=mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("D","BOOKING:B-1","FBA:ONT8"))).get(0);assertNull(external.orderId());assertNull(external.packages()); var transfer=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("C",3L))).get(0);assertEquals("FBA",transfer.destinationType());assertEquals("ACY2",transfer.fbaWarehouseCode());assertFalse(transfer.shipmentReferencesMissing()); assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing()); + var evidence=mapper.verifications(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))); + assertEquals(1,evidence.size());assertEquals(2,evidence.get(0).getVersion());assertEquals(20,evidence.get(0).getPackageCount()); } } -} \ No newline at end of file +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java index 5d7e0cd6a..fe14d1dd4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java @@ -13,7 +13,7 @@ class AppointmentCoverageServiceTest { return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false); } private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) { - return new AppointmentCoverageMapper.Fact(1L,"A","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles"); + return new AppointmentCoverageMapper.Fact(1L,"A","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",null); } @Test void completeQuantitySharesOneAppointmentWithItsActualConfirmationAndTimezone() { var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); @@ -42,7 +42,7 @@ class AppointmentCoverageServiceTest { when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L))); assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verify(mapper,never()).sources(anyList()); when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L))); - when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","BOOKING:B-1","BOOKING:2/ORDER:2","EXT-1","America/Los_Angeles"))); + when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","BOOKING:B-1","BOOKING:2/ORDER:2","EXT-1","America/Los_Angeles",null))); var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status()); assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")))); } @@ -60,6 +60,22 @@ class AppointmentCoverageServiceTest { var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0); assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES"))); assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt()); - verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper); + verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verify(mapper,times(1)).verifications(anyList()); verifyNoMoreInteractions(mapper); + } + @Test void independentVerificationSuppliesQuantityAndReferencesWithoutRewritingOriginalOrderFacts() { + var mapper=mock(AppointmentCoverageMapper.class); + when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",null,2,BEFORE,true))); + var evidence=new AppointmentCargoVerification();evidence.setCustomerId("A");evidence.setSourceKey("ORDER:1");evidence.setDestinationKey("FBA:ONT8"); + evidence.setSourceVersion("ORDER:2");evidence.setPackageCount(20);evidence.setVersion(1);evidence.setShipmentReferencesJson("[\"FBA-A\"]"); + when(mapper.verifications(anyList())).thenReturn(List.of(evidence)); + when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",1))); + var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); + var result=service.query(refs).get(0); + assertEquals("CONFIRMED",result.status());assertFalse(result.changeReviewRequired());assertFalse(result.missingFields().contains("FBA_SHIPMENT_REFERENCES")); + evidence.setVersion(2);evidence.setShipmentReferencesJson("[\"FBA-CHANGED\"]"); + var changed=service.query(refs).get(0); + assertTrue(changed.changeReviewRequired());assertTrue(changed.missingFields().contains("SOURCE_CHANGED_REVIEW")); + assertEquals(result.appointments(),changed.appointments(),"核实资料变更不能重写原确认时间"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 13f3ef39c..13e21e691 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -17,6 +17,53 @@ import static org.mockito.Mockito.*; class AppointmentPlanningAuthorizationTest { private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup"; + @Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + var sources=context.getBean(AppointmentSourceService.class); + var coverage=context.getBean(AppointmentCoverageService.class); + login(LIST);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8")); + login(CARGO);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8")); + login(LIST,CARGO); + assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(1L,"CLIENT"),1L,"A","FBA:ONT8")); + assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),0L,"A","FBA:ONT8")); + verifyNoInteractions(sources); + var ref=new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8"); + var source=new AppointmentSourceService.Source("BOOKING:B-1","A",1L,"IN-A","B-1",null,"100","美国仓","FBA:ONT8","ONT8",10,"BOOKING:2/ORDER:1",true,List.of()); + when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L)))).thenReturn(List.of(ref)); + when(sources.resolve("BOOKING:B-1","A","FBA:ONT8")).thenReturn(source); + var snapshot=new AppointmentCoverageService.Coverage("A","BOOKING:B-1","FBA:ONT8","PENDING",List.of(),List.of(),source.sourceVersion(),false); + when(coverage.query(List.of(ref))).thenReturn(List.of(snapshot)); + assertEquals(source,controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8").data().source()); + verify(sources,never()).resolve("ORDER:1","A","FBA:ONT8"); + when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)))).thenReturn(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1",null))); + when(sources.resolve("ORDER:1","OTHER","FBA:ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND)); + assertEquals(404,assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"OTHER","FBA:ONT8")).getStatusCode().value()); + verify(coverage,times(1)).query(anyList()); + } finally {SecurityContextHolder.clearContext();} + } + @Test void batchContextRequiresBothListPermissionsAndRejectsCustomerIdentity() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController.class); + login(LIST);assertThrows(AccessDeniedException.class,()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1")); + login(LIST,CARGO);assertThrows(ResponseStatusException.class,()->controller.detail(user(1L,"CLIENT"),"FBA_APPOINTMENT:1")); + assertDoesNotThrow(()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1")); + } finally {SecurityContextHolder.clearContext();} + } + @Test void cargoVerificationRequiresIndependentWritePermissionAndPlatformIdentity() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentCargoVerificationController.class); + var request=new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,10,List.of("FBA-A"),null,"verify"); + login(LIST);assertThrows(AccessDeniedException.class,()->controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null)); + login(LIST,CARGO);controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null); + assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request)); + login(LIST,CARGO,WRITE);assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request)); + login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT); + assertThrows(ResponseStatusException.class,()->controller.save(user(1L,"CLIENT"),"ORDER:1","A",request)); + controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request); + verify(context.getBean(AppointmentCargoVerificationService.class)).save("ORDER:1","A",request,"7","operator"); + } finally {SecurityContextHolder.clearContext();} + } @Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var controller=context.getBean(AppointmentSourceCreateController.class); @@ -24,9 +71,12 @@ class AppointmentPlanningAuthorizationTest { "100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key"); login(LIST,CARGO,WRITE); assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request)); + assertThrows(AccessDeniedException.class,()->controller.repartition(user(0L,"PLATFORM"),null)); login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT); assertThrows(ResponseStatusException.class,()->controller.create(user(1L,"CLIENT"),request)); + assertThrows(ResponseStatusException.class,()->controller.repartition(user(1L,"CLIENT"),null)); assertDoesNotThrow(()->controller.create(user(0L,"PLATFORM"),request)); + assertDoesNotThrow(()->controller.repartition(user(0L,"PLATFORM"),null)); login(LIST);assertDoesNotThrow(()->controller.unscheduled(user(0L,"PLATFORM"),1,20)); } finally {SecurityContextHolder.clearContext();} } @@ -91,6 +141,9 @@ class AppointmentPlanningAuthorizationTest { private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); } @Configuration @EnableMethodSecurity static class Config { + @Bean com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController batchController() {return new com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class));} + @Bean AppointmentCargoVerificationService verificationService() {return mock(AppointmentCargoVerificationService.class);} + @Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);} @Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); } @Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); } @Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandServiceTest.java new file mode 100644 index 000000000..820fa3a18 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandServiceTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +class AppointmentWorkCommandServiceTest { + @Test void sameCommandReplaysItsOriginalSnapshotAfterLaterChangesAndRejectsChangedContent() throws Exception { + var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state); + var first=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"已联系\"}"); + var saved=f.commands.execute(state.getWorkItemKey(),first,ACTOR); + f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"second","{\"ownerId\":\"1\",\"followupDate\":\"2026-10-16\",\"note\":\"等待回复\"}"),ACTOR); + assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR)); + assertEquals(2,f.history.size()); assertNull(saved.item().followupDate()); + var changed=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"不同内容\"}"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),changed,ACTOR)).getStatusCode().value()); + var other=new Actor("2","运营乙",ACTOR.permissions()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,other)).getStatusCode().value()); + when(f.gateway.facts(anyList())).thenThrow(new IllegalStateException("source temporarily offline")); + assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR)); + var revoked=new Actor("1","运营甲",Set.of(LIST,FOLLOWUP,MANAGE)); + assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,revoked)).getStatusCode().value()); + } + @Test void directRouteGeneratesFourTypesOnceAndDoesNotCreateWarehouseReceipt() throws Exception { + var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(route); + var requirement=new Requirement("requirement-1",null,null,"CUSTOMER","洛杉矶港","客户仓","America/Los_Angeles",List.of(),List.of(),List.of()); + when(f.gateway.container("1")).thenReturn(new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,"America/Los_Angeles",List.of(requirement))); + var request=f.command(Command.CONFIRM_ROUTE,0,"route","{\"flow\":\"DIRECT\",\"reason\":\"按客户直送要求\"}"); + var result=f.commands.execute(route.getWorkItemKey(),request,ACTOR); + assertEquals(5,f.rows.size()); assertEquals(5,result.affectedWorkItemKeys().size()); + assertTrue(f.store.get(route.getWorkItemKey()).isRetired()); + assertFalse(f.store.all().stream().anyMatch(s->s.getType()==Type.WAREHOUSE_RECEIPT)); + assertEquals(result,f.commands.execute(route.getWorkItemKey(),request,ACTOR)); assertEquals(5,f.rows.size()); + assertThrows(ResponseStatusException.class,()->f.commands.execute(route.getWorkItemKey(),f.command(Command.CONFIRM_ROUTE,1,"switch","{\"flow\":\"TRANSFER\"}"),ACTOR)); + } + @Test void externalHandoffRequiresExplicitAcceptanceAndOrdinaryReplyCannotClearIt() throws Exception { + var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(state); + f.commands.execute(state.getWorkItemKey(),f.command(Command.HANDOFF_RETURN,0,"handoff","{\"recipient\":{\"kind\":\"EXTERNAL\",\"id\":null,\"name\":\"新车队\"},\"reason\":\"原承运另有任务\"}"),ACTOR); + f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"reply","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"等待新车队回复\"}"),ACTOR); + assertTrue(pendingHandoff(f.store.get(state.getWorkItemKey()))); + assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"empty","{\"handoffVersion\":1,\"note\":\"\"}"),ACTOR)); + var accepted=f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"accept","{\"handoffVersion\":1,\"note\":\"已电话确认接手,联系人张先生\"}"),ACTOR); + assertEquals(HandoffStatus.ACCEPTED,f.store.get(state.getWorkItemKey()).getReturnHandoff().status()); + assertEquals(ExecutionStatus.PENDING,accepted.item().executionStatus()); assertNull(accepted.item().actual()); + assertEquals("1",f.history.get(f.history.size()-1).getOperatorId()); + } + @Test void firstRequirementDoesNotReviewDownstreamButRealRescheduleDoes() throws Exception { + var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); f.add(receipt); + var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); delivery.setTarget(slot("2026-10-17")); f.add(delivery); + var payload="{\"target\":{\"date\":\"2026-10-15\",\"startTime\":null,\"endDate\":null,\"endTime\":null,\"timezone\":\"America/Los_Angeles\"},\"bookingRequired\":true,\"receivingRequirements\":\"先接仓\",\"reason\":\"首次目标\"}"; + f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,0,"first",payload),ACTOR); + assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); + f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,1,"second",payload.replace("2026-10-15","2026-10-18").replace("首次目标","改期")),ACTOR); + assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertEquals(1,f.store.get(delivery.getWorkItemKey()).getReviewVersion()); + assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"old","{\"reviewVersion\":0,\"note\":\"旧影响\"}"),ACTOR)); + var resolved=f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"review","{\"reviewVersion\":1,\"note\":\"已核对安排\"}"),ACTOR); + assertFalse(resolved.item().reviewRequired()); assertFalse(f.store.get(receipt.getWorkItemKey()).isReviewRequired()); + } + @Test void unsupportedPayloadAndReadOnlyActorCannotWrite() throws Exception { + var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state); + var readonly=new Actor("1","只读",Set.of(LIST,ORDER_READ)); + var request=f.command(Command.SAVE_FOLLOWUP,0,"one","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\"}"); + assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),request,readonly)).getStatusCode().value()); + var invalid=f.command(Command.SAVE_FOLLOWUP,0,"two","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\",\"executionStatus\":\"COMPLETED\"}"); + assertEquals(400,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),invalid,ACTOR)).getStatusCode().value()); + assertEquals(0,f.store.get(state.getWorkItemKey()).getVersion()); assertTrue(f.history.isEmpty()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentServiceTest.java new file mode 100644 index 000000000..2f2838cc0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentServiceTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.slot; + +class AppointmentWorkCommitmentServiceTest { + @Test void firstTargetSameDayConfirmationAndFirstAssignmentAreNotImpacts() { + var empty=new Commitment(null,null,null,"cargo",false); + var planned=new Commitment(slot("2026-10-16"),null,null,"cargo",false); + var confirmed=new Commitment(planned.target(),slot("2026-10-16"),"vehicle-1","cargo",false); + assertFalse(impacts(empty,planned)); assertFalse(impacts(planned,confirmed)); + assertFalse(impacts(planned,new Commitment(null,confirmed.confirmed(),null,"cargo",false))); + assertTrue(impacts(planned,new Commitment(planned.target(),slot("2026-10-17"),null,"cargo",false))); + assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),"vehicle-2","cargo",false))); + assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),confirmed.assignment(),"changed",false))); + assertTrue(impacts(confirmed,new Commitment(null,null,null,"cargo",true))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java new file mode 100644 index 000000000..fec6649a0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +class AppointmentWorkContractTest { + @Test void actionTargetsSerializeWithDiscriminatorAndSlotsNeverInventSeconds() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var action=new Action(ActionCode.VERIFY_CARGO,"核实资料","work-1",new CargoTarget(new SourceReference("12","ORDER:1","FBA:2"))); + var tree=json.readTree(json.writeValueAsString(action)); + assertEquals("CARGO_VERIFICATION",tree.path("target").path("kind").asText()); + assertEquals("12",tree.path("target").path("source").path("customerId").asText()); + assertEquals(action,json.treeToValue(tree,Action.class)); + var slot=new Slot(LocalDate.of(2026,10,15),LocalTime.of(10,30),null,null,"America/Los_Angeles"); + assertEquals("10:30",json.readTree(json.writeValueAsString(slot)).path("startTime").asText()); + var precise=new Slot(slot.date(),LocalTime.of(10,30,45,123000000),null,null,null); + assertEquals("10:30:45.123",json.readTree(json.writeValueAsString(precise)).path("startTime").asText()); + assertEquals(precise,json.readValue(json.writeValueAsString(precise),Slot.class)); + assertDoesNotThrow(()->AppointmentWorkPolicy.validateSourceSlot(precise)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->AppointmentWorkPolicy.validateSlot(precise)); + } + @Test void openApiDefinesFrozenPathsAndDiscriminatedCommands() throws Exception { + var options=new org.yaml.snakeyaml.LoaderOptions();options.setAllowDuplicateKeys(false); + Map root; try(var input=getClass().getResourceAsStream("/openapi/appointment-work-v1.yaml")) { assertNotNull(input); root=new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(input); } + var paths=(Map)root.get("paths"); var prefix="/api/platform/v1/last-leg/appointment-work"; + for(var path:List.of("/calendar","/summary","/items","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path)); + var schemas=(Map)((Map)root.get("components")).get("schemas"); + var command=(Map)schemas.get("CommandRequest"); assertEquals(6,((List)command.get("oneOf")).size()); + var item=(Map)((Map)schemas.get("WorkItem")).get("properties"); assertTrue(item.containsKey("containers")); assertFalse(item.containsKey("containerBookingId")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProviderTest.java new file mode 100644 index 000000000..958cb5b46 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProviderTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentWorkDirectTransportProviderTest { + @Test void pickupStartsDirectTransportButOnlyOriginalDestinationUnloadingCompletesIt() { + var client=mock(AppointmentWorkFirstlegClient.class);var provider=new AppointmentWorkDirectTransportProvider(client,"internal-test"); + var request=new FactRequest("W",Type.TRANSPORT,Flow.DIRECT,"1","INBOUND:1",null,null,List.of(),List.of(),List.of()); + var time=LocalDateTime.parse("2026-10-15T11:00:00"); + var pickup=fact("W/carrier",ExecutionStatus.COMPLETED,new Actual(null,time,null)); + when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.PENDING,null)))); + var moving=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.IN_PROGRESS,moving.executionStatus());assertNull(moving.actual());assertNull(moving.tripKey()); + when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.COMPLETED,new Actual(null,time.plusHours(2),null))))); + var done=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,done.executionStatus());assertEquals(time.plusHours(2),done.actual().localOccurredAt()); + assertFalse(provider.supports(Type.TRANSPORT,Flow.TRANSFER)); + } + private Fact fact(String key,ExecutionStatus execution,Actual actual) { + return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java new file mode 100644 index 000000000..5872e4ec5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; + +class AppointmentWorkFactAdapterTest { + @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { + var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); + var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of()); + assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of())); + assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),null)); + var target=new HashMap(); AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null))); + assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null)))); + } + @Test void completedFactRequiresActualEvidence() { + var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); + var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of()); + assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of(fact(state,null,null,ExecutionStatus.COMPLETED,null)))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java new file mode 100644 index 000000000..8b0139a66 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; + +class AppointmentWorkPersistenceTest { + @Test void ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception { + var ds=database(); + var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentWorkMapper.class); var store=new AppointmentWorkStore(mapper,new ObjectMapper().findAndRegisterModules()); + assertNotNull(store.lock()); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized); + var guard=store.lock(); guard.setInitialized(true); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized); + mapper.invalidateRequirements(); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized); + guard=store.lock(); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized); + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); store.insert(state); + assertThrows(RuntimeException.class,()->store.insert(state)); + var stale=store.get(state.getWorkItemKey()); state.setOwner(new Owner("1","运营甲")); store.save(state); + assertEquals(1,store.get(state.getWorkItemKey()).getVersion()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->store.save(stale)); + store.history(state,ACTOR,"SAVE_FOLLOWUP","等待回复","same-key","hash",Map.of("version",1)); + assertEquals("运营甲",store.replay(state.getWorkItemKey(),"same-key").getOperatorName()); + assertThrows(RuntimeException.class,()->store.history(state,new Actor("2","运营乙",ACTOR.permissions()),"SAVE_FOLLOWUP","其他人重复键","same-key","other",Map.of("version",2))); + assertEquals(1,store.history(state.getWorkItemKey()).size()); + } + } + @Test void transactionRollbackCannotLeaveHalfGeneratedRoute() throws Exception { + var ds=database(); var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class); + var factory=new MybatisSqlSessionFactoryBuilder().build(config); + try(var session=factory.openSession(false)) { + var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),new ObjectMapper().findAndRegisterModules()); + var state=new WorkTestFixture().state("1",Type.PICKUP,Flow.TRANSFER,null); store.insert(state); + store.history(state,ACTOR,"GENERATED","路线生成",null,null,null); session.rollback(); + } + try(var session=factory.openSession(true)) { assertTrue(session.getMapper(AppointmentWorkMapper.class).all(10).isEmpty()); } + } + private JdbcDataSource database() throws Exception { + var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:work-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + String ddl; try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")) { assertNotNull(stream); ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8); } + try(Connection connection=ds.getConnection(); var statement=connection.createStatement()) { for(var sql:ddl.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); } + return ds; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicyTest.java new file mode 100644 index 000000000..11bc6de9d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicyTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +class AppointmentWorkPolicyTest { + @Test void originalDeliveryScheduleDoesNotUndoTheRecordedNoBookingRequirement() { + var fixture=new WorkTestFixture();var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"DISPATCH:1");state.setDestinationKind("CUSTOMER");state.setBookingRequired(false); + var item=project(state,fact(state,null,slot("2026-10-16"),ExecutionStatus.PENDING,null),ACTOR,CLOCK); + assertEquals(PlanStatus.NOT_REQUIRED,item.planStatus());assertEquals(LocalDate.of(2026,10,16),item.calendarDate());assertEquals(ExecutionStatus.PENDING,item.executionStatus()); + } + @Test void localActualWithoutTimezoneRemainsCompletedOnItsRecordedDate() { + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); + var actual=new Actual(null,LocalDateTime.parse("2026-10-12T23:45:00"),null); + var item=project(state,fact(state,null,null,ExecutionStatus.COMPLETED,actual),ACTOR,CLOCK); + assertEquals(LocalDate.of(2026,10,12),item.calendarDate()); assertEquals(ExecutionStatus.COMPLETED,item.executionStatus()); + assertNull(item.timezone()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE")); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateActual(new Actual(null,null,null))); + } + @Test void sourcePlanWithoutTimezoneIsVisibleButNewRequirementRejectsIt() { + var source=new Slot(LocalDate.of(2026,10,16),LocalTime.of(10,30),null,null,null); + assertDoesNotThrow(()->validateSourceSlot(source)); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateSlot(source)); + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.PICKUP,Flow.TRANSFER,null); + var item=project(state,fact(state,null,source,ExecutionStatus.PENDING,null),ACTOR,CLOCK); + assertEquals(source.date(),item.calendarDate()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE")); + } + @Test void followupDateNeverBecomesAnAppointmentAndUnknownTimezoneUsesExplicitBusinessClock() { + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.ROUTE,Flow.UNKNOWN,null); state.setTimezone(null); state.setFollowupDate(LocalDate.of(2026,10,14)); + var item=project(state,null,ACTOR,CLOCK); + assertNull(item.calendarDate()); assertEquals(DateBasis.NONE,item.dateBasis()); assertTrue(followToday(item,CLOCK)); + assertTrue(item.missingFields().stream().anyMatch(s->s.contains("America/Los_Angeles"))); + assertEquals(ActionCode.CONFIRM_ROUTE,item.nextAction().code()); + } + @Test void actualDateWinsInItsOwnZoneAndBucketsAreExclusive() { + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); state.setReviewRequired(true); + var actual=new Actual(Instant.parse("2026-10-16T01:00:00Z"),null,"America/Los_Angeles"); + var fact=fact(state,slot("2026-10-12"),slot("2026-10-13"),ExecutionStatus.COMPLETED,actual); + var item=project(state,fact,ACTOR,CLOCK); + assertEquals(LocalDate.of(2026,10,15),item.calendarDate()); assertEquals(DateBasis.ACTUAL,item.dateBasis()); + assertEquals(Bucket.COMPLETED,bucket(item)); assertEquals(new BucketCounts(0,0,0,0,1),counts(List.of(item))); assertTrue(item.overdueReasons().isEmpty()); + assertEquals(ActionCode.VIEW_HISTORY,item.nextAction().code()); + } + @Test void nonexistentAndAmbiguousLocalTimesAreRejectedButDateOnlyStaysDateOnly() { + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,8),LocalTime.of(2,30),null,null,"America/Los_Angeles"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,11,1),LocalTime.of(1,30),null,null,"America/Los_Angeles"))); + assertDoesNotThrow(()->validateSlot(slot("2026-11-01"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,10,15),null,null,null,"-08:00"))); + } + @Test void overnightSlotRequiresExplicitEndDateAndValidEndLocalTime() { + var day=LocalDate.of(2026,10,15); + assertDoesNotThrow(()->validateSlot(new Slot(day,LocalTime.of(23,0),day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),null,LocalTime.of(1,0),"America/Los_Angeles"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),day,LocalTime.of(1,0),"America/Los_Angeles"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,null,day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles"))); + assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,7),LocalTime.of(23,0),LocalDate.of(2026,3,8),LocalTime.of(2,30),"America/Los_Angeles"))); + } + @Test void pendingAcceptanceSurvivesAnEmptyReminderAndDoesNotGrantExecution() { + var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); + state.setReturnHandoff(new ReturnHandoff(2,new Responsibility(ResponsibilityKind.EXTERNAL,null,"原车队"),new Responsibility(ResponsibilityKind.EXTERNAL,null,"新车队"),HandoffStatus.PENDING_ACCEPTANCE,"原车次结束")); + var item=project(state,fact(state,null,null,ExecutionStatus.PENDING,null),ACTOR,CLOCK); + assertTrue(followToday(item,CLOCK)); assertEquals(ActionCode.ACCEPT_RETURN,item.nextAction().code()); + assertEquals(ExecutionStatus.PENDING,item.executionStatus()); assertNull(item.actual()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java new file mode 100644 index 000000000..26a574500 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +class AppointmentWorkQueryServiceTest { + @Test void globalSummaryRetainsUnknownRoutesAndReturnAfterDeliveryAcrossCalendarFilters() { + var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); route.setFollowupDate(LocalDate.of(2026,10,14)); f.add(route); + var returns=f.state("2",Type.EMPTY_RETURN,Flow.DIRECT,null); returns.setTarget(slot("2026-09-01")); f.add(returns); + var delivered=f.state("2",Type.DELIVERY,Flow.DIRECT,"r1"); f.add(delivered); + f.facts.put(delivered.getWorkItemKey(),fact(delivered,null,null,ExecutionStatus.COMPLETED,new Actual(Instant.parse("2026-10-14T20:00:00Z"),null,"America/Los_Angeles"))); + assertEquals(new Summary(2,1,1,1,0),f.queries.summary(ACTOR)); + assertEquals(0,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),Flow.TRANSFER,Set.of(),ACTOR).total()); + assertEquals(2,f.queries.summary(ACTOR).all()); + var day=f.queries.items(View.ALL,1,20,LocalDate.of(2026,10,14),null,null,null,Bucket.COMPLETED,null,ACTOR); + assertEquals(1,day.total()); assertEquals(delivered.getWorkItemKey(),day.records().get(0).workItemKey()); + assertEquals(2,f.queries.items(View.ALL,1,20,null,null,null,null,null,null,ACTOR).total()); + } + @Test void canonicalTripIsOneWorkItemAndMatchesEveryAssociatedContainer() { + var f=new WorkTestFixture(); var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,null); trip.setTripKey("trip-1"); + trip.setContainers(List.of(new ContainerReference("1","CB-1","A"),new ContainerReference("2","CB-2","B"))); f.add(trip); + assertEquals(1,f.queries.summary(ACTOR).all()); + assertEquals(1,f.queries.items(View.ALL,1,20,null,null,null,"2",null,"B",ACTOR).total()); + assertEquals(2,f.queries.detail(trip.getWorkItemKey(),ACTOR).item().containers().size()); + } + @Test void incompleteBootstrapScopeDriftAndAuthorityFailureCannotProduceZero() { + var f=new WorkTestFixture(); f.guard.setInitialized(false); + assertEquals(503,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value()); + f.guard.setInitialized(true); when(f.gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"new-scope")); + assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value()); + when(f.gateway.discover(isNull(),anyInt())).thenThrow(new IllegalStateException("offline")); + assertThrows(IllegalStateException.class,()->f.queries.summary(ACTOR)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteServiceTest.java new file mode 100644 index 000000000..dcabbe27f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteServiceTest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +class AppointmentWorkRouteServiceTest { + @Test void onlyCurrentCommittedItemsDefineRouteAndNoGuardOrRemoteReadIsRequired() { + var fixture=new WorkTestFixture();var old=fixture.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);old.setRetired(true);fixture.add(old); + var current=fixture.state("1",Type.DELIVERY,Flow.DIRECT,"INBOUND:1");fixture.add(current); + assertEquals(Flow.DIRECT,new AppointmentWorkRouteService(fixture.store).route("1").flow()); + org.mockito.Mockito.verify(fixture.mapper,org.mockito.Mockito.never()).lockGuard(); + org.mockito.Mockito.verifyNoInteractions(fixture.gateway); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java new file mode 100644 index 000000000..f7f5770bb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.appointmentwork; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; + +class AppointmentWorkSyncServiceTest { + @Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() { + var f=new WorkTestFixture(); + var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2"); + var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:1","INBOUND:2"),List.of(source1,source2)); + var first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch)); + var second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10)); + var root=new Requirement("INBOUND:1",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source1)); + first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); + var remainingBatch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:2"),List.of(source2)); + second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(remainingBatch)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10)); + var active=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();assertEquals(2,active.size()); + var kept=active.stream().filter(s->"DISPATCH:1".equals(s.getBatchKey())).findFirst().orElseThrow(); + assertEquals(List.of("2"),kept.getContainers().stream().map(ContainerReference::containerBookingId).toList());assertEquals("2",kept.getContainerBookingId()); + assertFalse(kept.getPrerequisites().contains(AppointmentWorkPolicy.key("1",Type.WAREHOUSE_RECEIPT,null))); + assertFalse(f.store.container("1").stream().anyMatch(s->"DISPATCH:1".equals(s.getBatchKey()))); + assertEquals(List.of(source2),kept.getSources()); + var root2=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source2)); + second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root2)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10)); + assertEquals(2,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired() && s.getBatchKey()==null).count()); + } + @Test void removedPrivateBatchCannotBeReplacedByAnotherCustomersUnrelatedDemand() { + var f=new WorkTestFixture();var oldSource=new SourceReference("C1","ORDER:1","CUSTOMER:1"); + var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,null,null,List.of(),List.of(),List.of(oldSource)); + var container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10)); + var unrelated=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,null,null,List.of(),List.of(),List.of(new SourceReference("C2","ORDER:2","CUSTOMER:2"))); + container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(unrelated)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope")); + assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10))); + } + @Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() { + var f=new WorkTestFixture(); + var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of()); + var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of()); + for(var requirement:List.of(root,batch,root,batch)) { + var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); + f.sync.synchronize(new SyncRequest(null,10)); + var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList(); + assertEquals(1,deliveries.size()); assertEquals(requirement.requirementKey(),deliveries.get(0).getRequirementKey()); + } + } + @Test void localRequirementVersionChangingBetweenPagesCannotBePublishedComplete() { + var f=new WorkTestFixture();f.guard.setScanCursor("10");f.guard.setScanScopeVersion("scope");f.guard.setScanRequirementVersion(1);f.guard.setRequirementVersion(2); + when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"scope")); + assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10))); + assertEquals(0,f.guard.getSynchronizedRequirementVersion()); + } + @Test void serverDiscoveryCreatesUnknownRouteWithoutWarehouseBindingAndRetainsOldUnreturnedWork() { + var f=new WorkTestFixture(); var returns=f.state("old",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(returns); + var source=new ContainerSource("new","柜new",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); + assertEquals(1,f.sync.synchronize(new SyncRequest(null,10)).created()); + assertEquals(0,f.sync.synchronize(new SyncRequest(null,10)).created()); + assertEquals(2,f.rows.size()); assertNotNull(f.store.get(returns.getWorkItemKey())); + assertTrue(f.store.all().stream().anyMatch(s->s.getFlow()==Flow.UNKNOWN && s.getType()==Type.ROUTE)); + } + @Test void threeTransferRequirementsGenerateOnlyOnePickupReceiptAndReturn() { + var f=new WorkTestFixture(); + var requirements=List.of("one","two","three").stream().map(id->new Requirement(id,null,null,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())).toList(); + var source=new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",requirements); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); + f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10)); + assertEquals(9,f.rows.size()); + for(var type:List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN)) assertEquals(1,f.store.all().stream().filter(s->s.getType()==type).count()); + } + @Test void firstConfirmationUsesBaselineWhileSubsequentImpactMarksOnlyArrangedRelatedWork() { + var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); receipt.setTarget(slot("2026-10-15")); receipt.setObservedFingerprint("old"); receipt.setObservedImpactToken("same-impact"); receipt.setObservedArranged(true); f.add(receipt); + var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setTarget(slot("2026-10-16")); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(delivery); + var unscheduled=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r2"); unscheduled.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(unscheduled); + var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null); + var facts=new HashMap(); facts.put(receipt.getWorkItemKey(),first); facts.put(delivery.getWorkItemKey(),fact(delivery,null,null,ExecutionStatus.PENDING,null)); facts.put(unscheduled.getWorkItemKey(),fact(unscheduled,null,null,ExecutionStatus.PENDING,null)); + f.sync.observe(f.store.all(),facts,ACTOR); assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); + facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null)); + f.sync.observe(f.store.all(),facts,ACTOR); + assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertFalse(f.store.get(unscheduled.getWorkItemKey()).isReviewRequired()); + int version=f.store.get(delivery.getWorkItemKey()).getReviewVersion(); f.sync.observe(f.store.all(),facts,ACTOR); assertEquals(version,f.store.get(delivery.getWorkItemKey()).getReviewVersion()); + } + @Test void changingDiscoveryScopeDuringPaginationIsNotMarkedComplete() { + var f=new WorkTestFixture(); f.guard.setInitialized(false); f.guard.setScanCursor("10"); f.guard.setScanScopeVersion("before"); + when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"after")); + assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10))); + assertFalse(f.guard.isInitialized()); + } + @Test void sharedTripAcrossTwoContainersIsPersistedOnceAndKeepsBothContainers() { + var f=new WorkTestFixture(); + var sources=List.of("1","2").stream().map(id->new ContainerSource(id,"柜"+id,Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles", + List.of(new Requirement("R"+id,"B"+id,"AP"+id,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())))).toList(); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(sources,null,true,"scope")); + when(f.gateway.facts(anyList())).thenAnswer(call->{ + var values=new HashMap(); + for(var state:call.>getArgument(0)) if(state.getType()!=Type.ROUTE) { + if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING, + slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null)); + else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null)); + } + return values; + }); + f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10)); + var transports=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList(); + assertEquals(1,transports.size()); assertEquals(2,transports.get(0).getContainers().size()); assertEquals(List.of("B1","B2"),transports.get(0).getBatchKeys()); + assertEquals(9,f.store.all().stream().filter(s->!s.isRetired()).count()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java new file mode 100644 index 000000000..9868512d2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.appointmentwork; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +final class WorkTestFixture { + static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-15T04:00:00Z"),ZoneOffset.UTC); + static final Actor ACTOR=new Actor("1","运营甲",Set.of(LIST,ORDER_READ,FOLLOWUP,MANAGE)); + final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + final AppointmentWorkMapper mapper=mock(AppointmentWorkMapper.class); + final AppointmentWorkFactGateway gateway=mock(AppointmentWorkFactGateway.class); + final Map rows=new TreeMap<>(); + final Map facts=new HashMap<>(); + final List history=new ArrayList<>(); + final AppointmentWorkMapper.Guard guard=new AppointmentWorkMapper.Guard(); + final AppointmentWorkStore store=new AppointmentWorkStore(mapper,json); + final AppointmentWorkSyncService sync=new AppointmentWorkSyncService(store,gateway); + final AppointmentWorkQueryService queries=new AppointmentWorkQueryService(store,gateway,CLOCK); + final AppointmentWorkCommandService commands=new AppointmentWorkCommandService(store,gateway,sync,queries); + WorkTestFixture() { + guard.setId(1); guard.setInitialized(true); guard.setScopeVersion("scope"); + when(mapper.guard()).thenReturn(guard); when(mapper.lockGuard()).thenReturn(guard); + when(mapper.get(anyString())).thenAnswer(c->rows.get(c.getArgument(0))); + when(mapper.all(anyInt())).thenAnswer(c->rows.values().stream().limit(c.getArgument(0,Integer.class)).toList()); + when(mapper.container(anyString())).thenAnswer(c->rows.values().stream().filter(r->r.getContainerBookingId().equals(c.getArgument(0))).toList()); + when(mapper.insert(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); if(rows.putIfAbsent(row.getWorkItemKey(),row)!=null) throw new org.springframework.dao.DuplicateKeyException("duplicate"); return 1; }); + when(mapper.update(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); var old=rows.get(row.getWorkItemKey()); if(old==null || old.getVersion()!=row.getVersion()-1) return 0; rows.put(row.getWorkItemKey(),row); return 1; }); + when(mapper.insertHistory(any(AppointmentWorkMapper.History.class))).thenAnswer(c->{history.add(c.getArgument(0)); return 1;}); + when(mapper.selectHistory(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0))).toList()); + when(mapper.command(anyString(),anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && Objects.equals(h.getCommandKey(),c.getArgument(1))).findFirst().orElse(null)); + when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); + when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); + when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); + when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements()); + } + AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { + var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); + state.setContainers(List.of(new ContainerReference(container,"CB-"+container,"柜"+container))); state.setType(type); state.setFlow(flow); state.setRequirementKey(requirement); state.setSourceVersion("v1"); state.setTimezone("America/Los_Angeles"); + return state; + } + void add(AppointmentWorkState state) { store.insert(state); } + CommandRequest command(Command command,int version,String id,String payload) throws Exception { return new CommandRequest(command,version,id,json.readTree(payload)); } + static Slot slot(String date) { return new Slot(LocalDate.parse(date),null,null,null,"America/Los_Angeles"); } + static Fact fact(AppointmentWorkState state,Slot target,Slot confirmed,ExecutionStatus execution,Actual actual) { + return new Fact(state.getWorkItemKey(),"fact-v1","impact-v1",target!=null || confirmed!=null,confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED, + execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java new file mode 100644 index 000000000..be9f6ab1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.contract; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.util.List; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkSchemaContractTest { + @Test void completeWorkAndPhysicalAllocationSchemaMatchesBothInitializationEntries() throws Exception { + String source=Files.readString(Path.of("src/main/resources/db/appointment-work.sql")); + for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) { + String target=Files.readString(Path.of(path)); + for(String table:List.of("ll_dispatch_appointment_allocation","ll_appointment_work_commitment","ll_appointment_work_guard","ll_appointment_work","ll_appointment_work_history")) + assertEquals(definition(source,table),definition(target,table),path+" differs for "+table); + assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE")); + } + assertTrue(source.contains("scan_cursor VARCHAR(128)")); + assertTrue(source.contains("PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key)")); + } + @Test void overseasInitializationReportsSuccessOnlyAfterEveryTableAndSeed() throws Exception { + String sql=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + long count=Pattern.compile("(?m)^CREATE TABLE\\s+").matcher(sql).results().count(); + String pass="SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, "+count+" AS initialized_tables;"; + assertTrue(sql.stripTrailing().endsWith(pass)); + assertEquals(1,Pattern.compile("SELECT 'PASS' AS initialization_status").matcher(sql).results().count()); + String reset=Files.readString(Path.of("../deploy/sql/reset-test-fba-appointments.sql")); + assertTrue(reset.indexOf("CALL assert_test_appointment_reset_preconditions()")();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to}); jdbc.batchUpdate(insert,many); assertThat(mapper.calendar(from,to)).hasSize(2001); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 2c5d7d03b..4dd407347 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -146,7 +146,10 @@ class FleetTripPersistenceTest { preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); planning=mock(FleetDispatchPlanningGateway.class); ReflectionTestUtils.setField(service,"planning",planning); + ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class)); + ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); + ReflectionTestUtils.setField(execution,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 7489c6b3f..b4ca55031 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -4,11 +4,34 @@ import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; class InboundPlanSchemaContractTest { + @Test + void splitBatchAndVerificationDefinitionsMatchEveryDeploymentEntry() throws IOException { + String source=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(var file:List.of("../deploy/sql/海外区域统一增量-MySQL8.sql","../deploy/sql/reset-test-fba-appointments.sql")) { + String deployment=Files.readString(Path.of(file)); + for(var name:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_appointment_cargo_verification")) { + assertEquals(definition(source,name),definition(deployment,name),file+" must preserve allocation and history for "+name); + } + } + } + + private static String definition(String sql,String name) { + var matcher=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+Pattern.quote(name)+" \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql); + assertTrue(matcher.find(),name+" must exist"); + String definition=matcher.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n"); + assertFalse(matcher.find(),name+" must have one definition"); + return definition; + } @Test void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException { try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { @@ -33,8 +56,11 @@ class InboundPlanSchemaContractTest { assertTrue(appointment.contains("confirmation_no varchar(128) null")); assertTrue(appointment.contains("planned_date date null")); assertTrue(appointment.contains("appointment_start_utc datetime(3) null")); - assertTrue(schema.contains("unique key uk_ll_fba_active_source (active_source_key)")); - assertTrue(schema.contains("unique key uk_ll_fba_active_order (active_order_id)")); + assertFalse(schema.contains("unique key uk_ll_fba_active_source (active_source_key)")); + assertFalse(schema.contains("unique key uk_ll_fba_active_order (active_order_id)")); + assertTrue(schema.contains("key idx_ll_fba_active_source (customer_id,source_key,active)")); + assertTrue(schema.contains("key idx_ll_fba_active_order (customer_id,order_id,active)")); + assertTrue(schema.contains("replacement_keys_json longtext null")); assertTrue(schema.contains("create table if not exists ll_fba_appointment_command")); String items = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment_item (")); assertTrue(items.substring(0, items.indexOf("engine=innodb")).contains("customer_id varchar(128) not null")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java index c46136ad3..a1952e1e3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -26,7 +26,7 @@ class InboundPlanDispatchStatusPersistenceTest { db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)"); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))"); db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,order_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20),fba_warehouse_code VARCHAR(30),active BOOLEAN)"); - db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,replacement_nos_json VARCHAR(1000))"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)"); db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); @@ -83,7 +83,7 @@ class InboundPlanDispatchStatusPersistenceTest { db.update("INSERT INTO ll_inbound_plan VALUES(4,'LIP-2','B',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(5,4,6,'IN-2','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_order VALUES(6,'B','LX-2','FIRST_LEG','LA','洛杉矶仓','客户B')"); - db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP)"); + db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP,NULL)"); db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,3,7,'A','ABE4',TRUE),(9,6,7,'B','ABE4',TRUE)"); assertThat(mapper.rows(null, query(null), 0, 20)).hasSize(2) .extracting(InboundPlanListResponse::appointmentNo).containsOnly("APT-SHARED"); @@ -95,6 +95,17 @@ class InboundPlanDispatchStatusPersistenceTest { } } + @Test void splitSourcesNeverExposeAnArbitraryLatestAppointmentAsTheWholeOrder() { + db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'AP-FIRST','SUCCESS',CURRENT_TIMESTAMP,NULL),(8,CURRENT_TIMESTAMP,'AP-SECOND','PENDING',NULL,NULL)"); + db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(9,3,7,'A','ABE4',TRUE),(10,3,8,'A','ABE4',TRUE)"); + var row=mapper.rows("A",query(null),0,20).get(0); + assertThat(row.appointmentStatus()).isEqualTo("MULTIPLE");assertThat(row.appointmentNo()).isNull();assertThat(row.appointmentAt()).isNull(); + db.update("UPDATE ll_inbound_fba_appointment_item SET active=FALSE WHERE id=10"); + db.update("UPDATE ll_inbound_fba_appointment SET appointment_status='CANCELLED' WHERE id=8"); + var remaining=mapper.rows("A",query(null),0,20).get(0); + assertThat(remaining.appointmentNo()).isEqualTo("AP-FIRST");assertThat(remaining.appointmentStatus()).isEqualTo("SUCCESS"); + } + private void dispatch(long id, String owner, String status, String itemOwner) { db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index f2f13a06e..67cfba200 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -82,6 +82,7 @@ class ReceivingContainerServiceBehaviorTest { @BeforeEach void setUp() { service = new ReceivingContainerService(); + ReflectionTestUtils.setField(service,"workRouteGuard",org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard.class)); ReflectionTestUtils.setField(service, "plans", plans); ReflectionTestUtils.setField(service, "lines", lines); ReflectionTestUtils.setField(service, "pallets", pallets); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java new file mode 100644 index 000000000..bcd2628c6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.inboundplan.dto.*; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.server.ResponseStatusException; +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentCargoVerificationPersistenceTest { + private static AppointmentCargoVerificationService.Save save(int version,Integer quantity,String key,String... refs) { + return new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",version,quantity,List.of(refs),"按原票核实",key); + } + private static SourceFbaAppointmentRequest appointment(String key,FbaAppointmentDetails details) { + return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")),"100","ONT8",details,key); + } + private static FbaAppointmentDetails pending() {return new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null);} + private static FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(null,null,null,"EXT-1",LocalDateTime.of(2026,10,20,9,0),null,"SUCCESS",null,null,null);} + private static void flags(AnnotationConfigApplicationContext context) { + when(context.getBean(AppointmentBatchPreviewMapper.class).flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false))); + } + @Test void savesWithoutAppointmentOrConfirmationAndReplaysOriginalVersionAfterLaterEdits() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(AppointmentCargoVerificationService.class); + var db=new JdbcTemplate(context.getBean(DataSource.class)); + var first=service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"operator","客服"); + assertEquals(1,first.version());assertEquals(List.of("FBA-A"),first.shipmentReferences()); + service.save("ORDER:1","A",save(1,9,"second","FBA-A-NEW"),"operator","客服"); + assertEquals(first,service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"operator","客服")); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); + assertEquals(0,db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)); + assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",save(0,10,"first","OTHER"),"operator","客服")); + assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"other-operator","客服")); + } + } + @Test void verificationEnforcesSourceLimitAndExistingAllocationAndSurvivesCancellation() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + flags(context); + var cargo=context.getBean(AppointmentCargoVerificationService.class); + var appointments=context.getBean(InboundFbaAppointmentService.class); + assertEquals(409,assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(0,11,"overflow"),"operator","客服")).getStatusCode().value()); + cargo.save("ORDER:1","A",save(0,8,"verified","FBA-A"),"operator","客服"); + var planned=appointments.createFromSources("operator",appointment("create",pending())); + assertEquals(8,planned.items().get(0).packageCount()); + assertEquals(1,planned.items().get(0).cargoVerificationVersion()); + assertEquals(List.of("FBA-A"),planned.items().get(0).shipmentReferences()); + assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(1,7,"under-allocated","FBA-A"),"operator","客服")); + var confirmed=appointments.update("operator",planned.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),0,"confirm")); + assertEquals("SUCCESS",confirmed.appointmentStatus(),"独立核实资料应满足预约货量和货件门禁,无需改写原订单明细"); + cargo.save("ORDER:1","A",save(1,8,"new-reference","FBA-RECHECKED"),"operator","客服"); + assertEquals(List.of("FBA-A"),appointments.detail(planned.appointmentNo()).items().get(0).shipmentReferences(),"后续核实不能静默改变原预约快照"); + appointments.delete("operator",planned.appointmentNo(),new CancelFbaAppointmentRequest(1,"对方取消")); + var db=new JdbcTemplate(context.getBean(DataSource.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class)); + assertEquals(0,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages()); + cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服"); + } + } + @Test void incompleteVerificationCanBeSavedButCannotConfirmAndChangedSourceMustBeReverified() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + flags(context); + var cargo=context.getBean(AppointmentCargoVerificationService.class); + var appointments=context.getBean(InboundFbaAppointmentService.class); + cargo.save("ORDER:1","A",save(0,10,"only-quantity"),"operator","客服"); + assertThrows(ResponseStatusException.class,()->appointments.createFromSources("operator",appointment("cannot-confirm",confirmed()))); + var changed=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",9,"ORDER:2",true,List.of()); + when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed)); + assertThrows(ResponseStatusException.class,()->appointments.createFromSources("operator",appointment("changed",pending()))); + assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(1,9,"stale-source","FBA-A"),"operator","客服")); + var request=new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:2",1,9,List.of("FBA-A"),null,"fresh-source"); + cargo.save("ORDER:1","A",request,"operator","客服"); + assertEquals(9,appointments.createFromSources("operator",appointment("fresh-create",pending())).items().get(0).packageCount()); + } + } + @Test void simultaneousCommandsWithTheSameExpectedVersionCannotOverwriteEachOther() throws Exception { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(AppointmentCargoVerificationService.class); + var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1); + try { + var one=executor.submit(()->attempt(service,start,"one"));var two=executor.submit(()->attempt(service,start,"two"));start.countDown(); + assertEquals(1,(one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)); + assertEquals(1,new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class)); + } finally {executor.shutdownNow();} + } + } + @Test void declaredQuantityCanStayUnknownUntilIndependentVerificationWithoutChangingSource() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + flags(context); + var source=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",null,"ORDER:1",true,List.of()); + var resolver=context.getBean(AppointmentSourceService.class); + when(resolver.resolveBatch(anyList())).thenReturn(List.of(source));when(resolver.resolve("ORDER:1","A",null)).thenReturn(source); + var service=context.getBean(AppointmentCargoVerificationService.class); + service.save("ORDER:1","A",save(0,12,"fill-quantity","FBA-A"),"operator","客服"); + var view=service.get("ORDER:1","A",null); + assertNull(view.source().packages());assertEquals(12,view.effectivePackages());assertEquals(12L,view.availablePackages()); + assertEquals(List.of("VERIFY_CARGO"),view.allowedActions()); + assertEquals(12,context.getBean(InboundFbaAppointmentService.class).createFromSources("operator",appointment("create",confirmed())).items().get(0).packageCount()); + } + } + private static boolean attempt(AppointmentCargoVerificationService service,CountDownLatch start,String key) throws Exception { + start.await();try {service.save("ORDER:1","A",save(0,10,key,"FBA-A"),"operator","客服");return true;} + catch(ResponseStatusException error) {assertEquals(409,error.getStatusCode().value());return false;} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index e75881c74..5809127db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -155,13 +155,12 @@ class FbaAppointmentPersistenceTest { assertNull(service.cancellationHistory(original.appointmentNo(),1,20).records().get(0).cancelReason()); } } - @Test void unknownQuantityOnReviewDoesNotSilentlyKeepThePreviousKnownQuantity() { + @Test void changedUnknownSourceQuantityBlocksReviewWithoutErasingTheAllocatedQuantity() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var service=context.getBean(InboundFbaAppointmentService.class);var saved=service.createFromSources("operator",request("create")); when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(cargo(null)); - var updated=service.update("operator",saved.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),0,"review")); - assertNull(updated.items().get(1).packageCount()); - assertNull(new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT package_count FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2'",Integer.class)); + assertThrows(ResponseStatusException.class,()->service.update("operator",saved.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),0,"review"))); + assertEquals(20,new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT package_count FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2'",Integer.class)); } } @Test void actualIncomingBindsTheOriginalBookingInItsOwnTransactionWithoutChangingPlannedFacts() { @@ -194,13 +193,15 @@ class FbaAppointmentPersistenceTest { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:appointment-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); String baseline;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);} try(var c=ds.getConnection();var statement=c.createStatement()) { - for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command")) { + for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command","ll_appointment_cargo_verification")) { int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0); String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql); } statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0)");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)"); statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)"); statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))"); + String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);} + for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); }return ds; } @Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);} @@ -208,6 +209,8 @@ class FbaAppointmentPersistenceTest { var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),dataSource));config.setMapUnderscoreToCamelCase(true); config.addMapper(InboundFbaAppointmentMapper.class);config.addMapper(InboundFbaAppointmentItemMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);config.addMapper(FbaAppointmentCommandMapper.class); config.addMapper(OrderVersionProbe.class); + config.addMapper(AppointmentCargoVerificationMapper.class); + config.addMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class); return new MybatisSqlSessionFactoryBuilder().build(config); } @Bean SqlSessionTemplate template(SqlSessionFactory factory){return new SqlSessionTemplate(factory);} @@ -215,13 +218,19 @@ class FbaAppointmentPersistenceTest { @Bean InboundFbaAppointmentItemMapper itemMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentItemMapper.class);} @Bean InboundFbaAppointmentQueryMapper queryMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentQueryMapper.class);} @Bean FbaAppointmentCommandMapper commands(SqlSessionTemplate session){return session.getMapper(FbaAppointmentCommandMapper.class);} - @Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenReturn(cargo(20));when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;} - @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false),new AppointmentBatchPreviewMapper.Flags(2L,false,false,false)));return mock;} + @Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);} + @Bean AppointmentCargoVerificationService verificationService(AppointmentSourceService sources,AppointmentCargoVerificationMapper mapper,ObjectMapper json){return new AppointmentCargoVerificationService(sources,mapper,json);} + @Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenAnswer(call->{List refs=call.getArgument(0);return refs.stream().map(ref->cargo(20).stream().filter(s->s.sourceKey().equals(ref.sourceKey()) && s.customerId().equals(ref.customerId())).findFirst().orElseThrow()).toList();});when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;} + @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} + @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);} + @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);} + @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);} + @Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes);} } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java new file mode 100644 index 000000000..f57084433 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java @@ -0,0 +1,150 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.inboundplan.dto.*; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.server.ResponseStatusException; +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FbaAppointmentRepartitionPersistenceTest { + private static SourceFbaAppointmentRequest create(String key) { + return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")), + "100","ONT8",pending(),key); + } + private static FbaAppointmentDetails pending(){return new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null);} + private static RepartitionFbaAppointmentsRequest.Allocation cargo(String customer,String key,int packages) {return new RepartitionFbaAppointmentsRequest.Allocation(customer,key,packages,"ORDER:1",null);} + private static RepartitionFbaAppointmentsRequest.Batch batch(RepartitionFbaAppointmentsRequest.Allocation... rows){return new RepartitionFbaAppointmentsRequest.Batch(null,null,List.of(rows));} + private static RepartitionFbaAppointmentsRequest split(FbaAppointmentResponse original,String key) { + return new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(original.appointmentNo(),original.version())), + List.of(batch(cargo("A","ORDER:1",4),cargo("B","ORDER:2",20)),batch(cargo("A","ORDER:1",6))),"按收货窗口分批",key); + } + @Test void splitAndMergeConserveEveryOriginalSourceAndReschedulingNeverExpandsBackToWholeOrder() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class)); + var original=service.createFromSources("operator",create("create")); + assertEquals("FBA_APPOINTMENT:"+original.id(),original.batchKey()); + assertTrue(service.batchContext(original.batchKey()).allowedActions().containsAll(List.of("SPLIT","MERGE"))); + var split=service.repartition("operator",split(original,"split")); + assertEquals(split,service.repartition("operator",split(original,"split"))); + assertEquals(2,split.appointments().size());assertEquals(2,service.unscheduled(1,20).total()); + var historical=service.detail(original.appointmentNo()); + assertEquals(split.appointments().stream().map(FbaAppointmentResponse::appointmentNo).toList(),historical.replacementAppointmentNos()); + assertEquals(split.appointments().stream().map(FbaAppointmentResponse::batchKey).toList(),historical.replacementBatchKeys()); + assertEquals(List.of(original.batchKey()),split.replacedBatchKeys()); + assertTrue(service.batchContext(original.batchKey()).allowedActions().isEmpty()); + assertEquals(3,service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).size()); + assertTrue(service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).isEmpty()); + assertTrue(service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).isEmpty()); + assertEquals(List.of(10,20),historical.items().stream().map(FbaAppointmentResponse.Item::packageCount).toList()); + assertEquals("按收货窗口分批",historical.repartitionReason()); + assertThrows(ResponseStatusException.class,()->service.update("operator",historical.appointmentNo(),new UpdateFbaAppointmentRequest(pending(),historical.version(),"old-edit"))); + assertThrows(ResponseStatusException.class,()->service.delete("operator",historical.appointmentNo(),new CancelFbaAppointmentRequest(historical.version(),"old-cancel"))); + assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",create("duplicate"))); + var first=split.appointments().get(0);var second=split.appointments().get(1); + var rescheduled=service.update("operator",second.appointmentNo(),new UpdateFbaAppointmentRequest(new FbaAppointmentDetails(LocalDate.of(2026,10,20),null,null,null,null,null,"PENDING",null,null,null),0,"reschedule")); + assertEquals(6,rescheduled.items().get(0).packageCount()); + var merged=service.repartition("operator",new RepartitionFbaAppointmentsRequest(List.of( + new RepartitionFbaAppointmentsRequest.Original(first.appointmentNo(),first.version()), + new RepartitionFbaAppointmentsRequest.Original(second.appointmentNo(),rescheduled.version())), + List.of(batch(cargo("A","ORDER:1",10),cargo("B","ORDER:2",20))),"同仓合约","merge")); + assertEquals(1,service.unscheduled(1,20).total());assertEquals(2,merged.appointments().get(0).items().size()); + assertEquals(10,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:1' AND active=1",Integer.class)); + assertEquals(20,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2' AND active=1",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Integer.class)); + assertEquals(0,db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class)); + } + } + @Test void staleSourceOrVerificationIntentCannotConsumeOrReplaceCurrentBatch() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class); + var verification=context.getBean(AppointmentCargoVerificationService.class); + var original=service.createFromSources("operator",create("create")); + var stale=split(original,"stale-verification"); + verification.save("ORDER:1","A",new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,10,List.of("FBA-A"),null,"verify"),"operator","客服"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.repartition("operator",stale)).getStatusCode().value()); + var contextAfterVerification=service.batchContext(original.batchKey()); + assertEquals(1,contextAfterVerification.sources().stream().filter(s->s.sourceKey().equals("ORDER:1")).findFirst().orElseThrow().cargoVerificationVersion()); + var badSource=new RepartitionFbaAppointmentsRequest(stale.originals(),List.of( + batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",4,"ORDER:0",1),cargo("B","ORDER:2",20)), + batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",6,"ORDER:1",1))),"来源已变化","stale-source"); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.repartition("operator",badSource)).getStatusCode().value()); + var fresh=new RepartitionFbaAppointmentsRequest(stale.originals(),List.of( + batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",4,"ORDER:1",1),cargo("B","ORDER:2",20)), + batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",6,"ORDER:1",1))),"刷新后分批","fresh"); + assertTrue(service.detail(original.appointmentNo()).replacementBatchKeys().isEmpty()); + assertEquals(2,service.repartition("operator",fresh).appointments().size()); + assertEquals(400,assertThrows(ResponseStatusException.class,()->service.batchContext("FBA_APPOINTMENT:0" )).getStatusCode().value()); + } + } + @Test void matchingGrandTotalCannotMoveQuantityAcrossCustomersOrOriginalSources() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class);var original=service.createFromSources("operator",create("create")); + var input=split(original,"bad"); + var wrong=new RepartitionFbaAppointmentsRequest(input.originals(),List.of(batch(cargo("A","ORDER:1",5)),batch(cargo("B","ORDER:2",25))),"错误分配","wrong-count"); + assertEquals(400,assertThrows(ResponseStatusException.class,()->service.repartition("operator",wrong)).getStatusCode().value()); + var customer=new RepartitionFbaAppointmentsRequest(input.originals(),List.of(batch(cargo("B","ORDER:1",10)),batch(cargo("A","ORDER:2",20))),"错误客户","wrong-customer"); + assertEquals(400,assertThrows(ResponseStatusException.class,()->service.repartition("operator",customer)).getStatusCode().value()); + assertTrue(service.detail(original.appointmentNo()).replacementAppointmentNos().isEmpty()); + assertEquals(1,service.unscheduled(1,20).total()); + } + } + @Test void anyInsertFailureRollsBackOldClaimsNewBatchesAndIdempotencyRecordTogether() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class)); + var original=service.createFromSources("operator",create("create")); + db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT reject_second CHECK(package_count<>6)"); + assertThrows(RuntimeException.class,()->service.repartition("operator",split(original,"retry"))); + assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE operation='REPARTITION'",Integer.class)); + assertTrue(service.detail(original.appointmentNo()).replacementAppointmentNos().isEmpty()); + db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT reject_second"); + assertEquals(2,service.repartition("operator",split(original,"retry")).appointments().size()); + } + } + @Test void unknownQuantityCanBeVerifiedThenSplitImmediatelyWithoutConfirmingOrRewritingOriginalOrder() { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var source=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",null,"ORDER:1",true,List.of()); + when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(source)); + var service=context.getBean(InboundFbaAppointmentService.class); + var original=service.createFromSources("operator",new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")),"100","ONT8",pending(),"create")); + var request=new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(original.appointmentNo(),0)), + List.of(batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",5,"ORDER:1",1)),batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",7,"ORDER:1",1))),"分两次送仓","split"); + assertThrows(ResponseStatusException.class,()->service.repartition("operator",request)); + context.getBean(AppointmentCargoVerificationService.class).save("ORDER:1","A",new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,12,List.of(),null,"verify"),"operator","客服"); + var editable=service.batchContext(original.batchKey()); + assertTrue(editable.allowedActions().contains("SPLIT"));assertEquals(12,editable.sources().get(0).packageCount()); + var result=service.repartition("operator",request); + assertEquals(List.of(5,7),result.appointments().stream().map(a->a.items().get(0).packageCount()).toList()); + assertTrue(result.appointments().stream().allMatch(a->a.appointmentAt()==null && "PENDING".equals(a.appointmentStatus()))); + assertNull(service.detail(original.appointmentNo()).items().get(0).packageCount()); + } + } + @Test void existingTransportAndConcurrentRepartitionCannotDuplicateWork() throws Exception { + try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { + var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class)); + var original=service.createFromSources("operator",create("create")); + db.update("INSERT INTO ll_dispatch_order VALUES(10,'A','DRAFT')");db.update("INSERT INTO ll_dispatch_item VALUES(1,'A',10)"); + db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,11,12,?,'A','ORDER:1','FBA:ONT8',1)",original.batchKey()); + assertThrows(ResponseStatusException.class,()->service.repartition("operator",split(original,"blocked"))); + db.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE id=10"); + var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1); + try { + var one=executor.submit(()->attempt(service,start,split(original,"one")));var two=executor.submit(()->attempt(service,start,split(original,"two")));start.countDown(); + assertEquals(1,(one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)); + assertEquals(2,service.unscheduled(1,20).total()); + } finally {executor.shutdownNow();} + } + } + private static boolean attempt(InboundFbaAppointmentService service,CountDownLatch start,RepartitionFbaAppointmentsRequest request) throws Exception { + start.await();try {service.repartition("operator",request);return true;}catch(ResponseStatusException error){assertEquals(409,error.getStatusCode().value());return false;} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 08f995e1a..412ae1062 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -33,6 +33,8 @@ class InboundFbaAppointmentServiceTest { headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class); commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class); service=new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class)); + ReflectionTestUtils.setField(service,"verification",mock(AppointmentCargoVerificationService.class)); ReflectionTestUtils.setField(service,"appointmentMapper",headers);ReflectionTestUtils.setField(service,"itemMapper",items);ReflectionTestUtils.setField(service,"queryMapper",query); ReflectionTestUtils.setField(service,"commands",commands);ReflectionTestUtils.setField(service,"sources",sources);ReflectionTestUtils.setField(service,"flags",flags);ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); when(commands.find(anyString())).thenAnswer(c->journal.get(c.getArgument(0))); @@ -41,6 +43,7 @@ class InboundFbaAppointmentServiceTest { when(sources.resolveBatch(anyList())).thenAnswer(c->{List refs=c.getArgument(0);return refs.stream().map(r->cargo.get(r.sourceKey())).toList();}); when(sources.destinationTimezone(anyString())).thenReturn("America/Los_Angeles"); when(query.lockOrders(anyList())).thenAnswer(c->c.getArgument(0)); + when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(0,0,0)); when(flags.flags(anyList())).thenAnswer(c->{List ids=c.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();}); when(headers.insert(any(InboundFbaAppointment.class))).thenAnswer(c->{storedHeaders.add(c.getArgument(0));return 1;}); when(headers.selectOne(any())).thenAnswer(c->storedHeaders.isEmpty()?null:storedHeaders.get(0)); @@ -148,9 +151,9 @@ class InboundFbaAppointmentServiceTest { assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("dst-gap",gap,"ORDER:1"))); } @Test void duplicateActiveSourceOrDatabaseClaimRaceCannotCreateAnotherAppointment() { - when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(1L); - assertEquals(409,assertThrows(ResponseStatusException.class,()->create("occupied")).getStatusCode().value());assertTrue(storedHeaders.isEmpty()); - when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(0L); + when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(20,1,0)); + assertEquals(409,assertThrows(ResponseStatusException.class,()->create("occupied")).getStatusCode().value());assertTrue(storedItems.isEmpty()); + when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(0,0,0)); when(items.insert(any(InboundFbaAppointmentItem.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("claimed")); assertEquals(409,assertThrows(ResponseStatusException.class,()->create("raced")).getStatusCode().value()); } @@ -183,7 +186,7 @@ class InboundFbaAppointmentServiceTest { service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段")); assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive()); assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"))); - when(query.countActiveSources(anyList(),anyList(),eq(result.id()))).thenReturn(1L); + when(query.allocation(anyString(),anyString(),nullable(Long.class),eq(result.id()))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(10,1,0)); assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore"))); } @Test void snowflakeLineSelectorsDeserializeWithoutPrecisionLoss() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java new file mode 100644 index 000000000..fc1bc82f8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentWorkDeliveryProviderTest { + @Test void privateCompletionCoversThisDispatchPerSourceWithoutCompletingTheWholeOrder() { + var appointments=mock(InboundFbaAppointmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class); + var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources); + when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",11,"C","ORDER:11","CUSTOMER:11",4),new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",12,"C","ORDER:12","CUSTOMER:12",6))); + var time=LocalDateTime.parse("2026-10-15T11:00:00"); + var one=new AppointmentWorkDeliveryMapper.CustomerDelivery(11,"C",4,"L-1","DELIVERED",10,10,time,time.minusDays(1)); + var two=new AppointmentWorkDeliveryMapper.CustomerDelivery(12,"C",6,"L-1","DELIVERED",10,10,time,time.minusDays(1)); + var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","DISPATCH:20","DISPATCH:20",null,List.of("DISPATCH:20"),List.of(),List.of()); + when(deliveries.privateBatch("DISPATCH:20")).thenReturn(List.of(one)); + assertNotEquals(ExecutionStatus.COMPLETED,provider.read(List.of(request)).get(0).executionStatus()); + when(deliveries.privateBatch("DISPATCH:20")).thenReturn(List.of(one,two)); + var fact=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,fact.executionStatus());assertEquals(time,fact.actual().localOccurredAt());assertEquals("D-20",fact.businessNo()); + when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of()); + var removed=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.PENDING,removed.executionStatus());assertNull(removed.actual());assertFalse(removed.blockReasons().isEmpty()); + verify(deliveries,never()).customer(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.anyString());verifyNoInteractions(appointments); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java new file mode 100644 index 000000000..bd1db5719 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class AppointmentWorkDispatchBindingTest { + @Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() { + var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class); + var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes); + var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4); + when(mapper.byDispatch(20)).thenReturn(List.of(row)); + var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4); + assertDoesNotThrow(()->service.requireReplay("C",20,List.of(allocation))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireReplay("C",20,List.of(new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:2","C","ORDER:1","FBA:ONT8",4)))); + verify(mapper,never()).remove(anyLong());verify(mapper,never()).insert(any());verifyNoInteractions(changes); + } + @Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() { + var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class); + var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes); + when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4))); + when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); + service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()); + var expected=new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4); + verify(mapper).insert(expected);verify(changes).requirementsChanged(); + when(mapper.bindings("D-20")).thenReturn(List.of(expected)); + when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,3))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20")); + when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4))); + assertDoesNotThrow(()->service.requireDeparture("D-20"));verifyNoInteractions(appointments); + } + @Test void repeatedPhysicalRowsForSameOrderResolveByIdentityWithoutIndexingDistinctResults() { + var sources=mock(AppointmentSourceService.class); + var one=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:A",4,4); + var two=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:B",5,5); + when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("C",1L)))) + .thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:X",null))); + var refs=AppointmentWorkPhysicalSources.canonical(List.of(one,two),sources); + assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,one).sourceKey()); + assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,two).sourceKey()); + } + @Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() { + var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class); + var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class); + var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes); + var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4); + when(mapper.physical(10,"C")).thenReturn(List.of(part)); + when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); + var batch=mock(FbaAppointmentResponse.class);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.replacementBatchKeys()).thenReturn(List.of()); + when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of())); + when(mapper.bindings("D-1")).thenReturn(List.of()); + when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4))); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1")); + verify(mapper,never()).insert(any()); verifyNoInteractions(changes); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java new file mode 100644 index 000000000..3a5824278 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentWorkDomainMapperTest { + @Test void originalDomainJoinsExecuteAgainstTheCurrentSchemaWithoutInventedColumns() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:work-domain-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + String baseline;try(var stream=getClass().getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(stream).readAllBytes(),StandardCharsets.UTF_8);} + try(var connection=ds.getConnection();var sql=connection.createStatement()) { + var created=new HashSet(); + for(var table:List.of("ll_order","ll_inbound_plan","ll_inbound_plan_line","ll_inbound_plan_pallet","ll_inbound_plan_pallet_allocation","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_dispatch_pallet_lock","ll_dispatch_order","ll_dispatch_item","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_receiving_container_session","ll_delivery_order","ll_fleet_driver","ll_vehicle","ll_fleet_trip","ll_fleet_trip_dispatch")) { + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");if(start<0) start=baseline.indexOf("CREATE TABLE "+table+" (");assertTrue(start>=0,table); + sql.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";")); + created.add(table); + } + // The current initialization script defines some fields in guarded ADD COLUMN literals. + // Execute those original definitions too; do not invent test-only business columns. + var additions=java.util.regex.Pattern.compile("'(ALTER TABLE (\\w+) ADD COLUMN (?:''|[^'])*)'").matcher(baseline); + while(additions.find()) if(created.contains(additions.group(2))) { + var prefix="ALTER TABLE "+additions.group(2)+" "; + for(var addition:additions.group(1).substring(prefix.length()).replace("''","'").split(",\\s*(?=ADD COLUMN)")) + sql.execute(prefix+addition.replace("ADD COLUMN ","ADD COLUMN IF NOT EXISTS ")); + } + String work;try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(stream).readAllBytes(),StandardCharsets.UTF_8);} + for(var statement:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!statement.isBlank()) sql.execute(statement); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); + for(var mapper:List.of(AppointmentWorkDispatchMapper.class,AppointmentWorkDeliveryMapper.class,AppointmentWorkSourceMapper.class,AppointmentWorkReceivingMapper.class,AppointmentWorkTripMapper.class)) config.addMapper(mapper); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) { + var dispatch=session.getMapper(AppointmentWorkDispatchMapper.class); + assertTrue(dispatch.candidates(1).isEmpty());assertTrue(dispatch.physical(1,"C").isEmpty());assertTrue(dispatch.pickedPhysical(1,"C").isEmpty()); + assertTrue(dispatch.dispatchPhysical("D").isEmpty());assertTrue(dispatch.bindings("D").isEmpty());assertTrue(dispatch.byDispatch(1).isEmpty());assertTrue(dispatch.trips("FBA_APPOINTMENT:1").isEmpty()); + assertEquals(0,dispatch.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",1)); + var deliveries=session.getMapper(AppointmentWorkDeliveryMapper.class);assertTrue(deliveries.batch("FBA_APPOINTMENT:1").isEmpty());assertTrue(deliveries.customer("1","1").isEmpty()); + assertTrue(deliveries.privateBatch("DISPATCH:1").isEmpty()); + var sources=session.getMapper(AppointmentWorkSourceMapper.class);assertTrue(sources.containerSources("1").isEmpty());assertTrue(sources.privateDispatches("1").isEmpty());assertTrue(sources.privateBatch("DISPATCH:1").isEmpty()); + var receiving=session.getMapper(AppointmentWorkReceivingMapper.class);assertTrue(receiving.plans("1").isEmpty());assertNull(receiving.session("1")); + var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1)); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuardTest.java new file mode 100644 index 000000000..caf353914 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuardTest.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentwork.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +class AppointmentWorkReceivingGuardTest { + @Test void confirmedLocalDirectRouteRejectsReceivingBeforeRemoteFactsExist() { + var firstleg=mock(AppointmentWorkFirstlegClient.class); var routes=mock(AppointmentWorkRouteService.class); + when(routes.route("1")).thenReturn(new AppointmentWorkRouteService.Route(Flow.DIRECT)); + var guard=new AppointmentWorkReceivingGuard(firstleg,routes,"internal-token"); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->guard.requireWarehouseFlow("1")); + verifyNoInteractions(firstleg); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java new file mode 100644 index 000000000..433d3cc22 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +class AppointmentWorkReceivingProviderTest { + @Test void receivingIsInProgressWithoutInventingAnActualCompletionAndTargetCarriesRealIdentity() { + var mapper=mock(AppointmentWorkReceivingMapper.class); var provider=new AppointmentWorkReceivingProvider(mapper); + var plan=new InboundPlan(); plan.setId(10L);plan.setPlanNo("IP-REAL");plan.setCustomerId("C");plan.setPlannedArrivalDate(LocalDate.of(2026,10,16)); + var session=new ReceivingContainerSession();session.setId(2L);session.setStatus("RECEIVING");session.setReceivingAt(LocalDateTime.of(2026,10,15,10,0)); + when(mapper.plans("1")).thenReturn(List.of(plan)); when(mapper.session("1")).thenReturn(session); + var request=new FactRequest("work",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,"1",null,null,null,List.of(),List.of(),List.of()); + var fact=provider.read(List.of(request)).get(0); + assertEquals(ExecutionStatus.IN_PROGRESS,fact.executionStatus()); assertNull(fact.actual()); + assertEquals(new ReceiptTarget("10","IP-REAL","C","1"),fact.action().target()); + session.setStatus("COMPLETED"); session.setCompletedAt(LocalDateTime.of(2026,10,16,12,0)); + fact=provider.read(List.of(request)).get(0); assertEquals(ExecutionStatus.COMPLETED,fact.executionStatus()); assertEquals(session.getCompletedAt(),fact.actual().localOccurredAt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java new file mode 100644 index 000000000..4e3d15f89 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; + +class AppointmentWorkRequirementProviderTest { + @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { + var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); + var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); + when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); + var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); + when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); + var manifest=provider.requirements(container,Flow.TRANSFER); + assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); + assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); + when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); + var restored=provider.requirements(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + verifyNoInteractions(sources,cargo,appointments); + } + @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { + var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); + var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); + var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); + assertEquals(List.of(requirement),provider.requirements(source,Flow.DIRECT)); + verifyNoInteractions(sources,cargo,appointments,mapping); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 1da8010e0..7a2395d42 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -46,7 +46,9 @@ "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" }, - "forbiddenSlices": ["security-projection"], + "forbiddenSlices": [ + "security-projection" + ], "forbiddenTypePrefixes": [ "com.baiyue.lastleg.security.controller.LastlegSecurity", "com.baiyue.lastleg.security.dto.LastlegSecurity", @@ -54,227 +56,237 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ - { - "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", - "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", - "reason": "退货换标合箱沿用PDA冻结仓身份作为操作人与仓库约束,不引用入仓业务服务或持久化。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferReceiptCoordinator", - "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", - "reason": "转仓实收复用仓储资源HTTP网关,校验及预留目标托盘库位;资源权威仍在Warehouse,不读取入仓持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteModels", - "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteService", - "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsModels", - "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsService", - "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests", - "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.inventory.query.service.InventoryQueryService", - "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos", - "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService", - "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests", - "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService", - "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels", - "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService", - "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" - }, - { - "source": "com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressController", - "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", - "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" - }, - { - "source": "com.baiyue.lastleg.export.LastlegExportProviderService", - "target": "com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport", - "reason": "统一导出装配器只调用自营周转的公开导出投影,复用固定版本查询;不开放自营Mapper或客户WMS库存访问,不产生反向依赖。" - }, - { - "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", - "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", - "reason": "费用适配器仅实现Fleet公司成本换算与Outbox端口,Fleet不读取费用模块或Outbox持久化实现;同事务发布且不新增package循环。" - }, - { - "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", - "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", - "reason": "转运适配器实现外接来货端口,仅传登记与实测证据;车队不读取转运Mapper或库存表。" - }, - { - "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", - "target": "com.baiyue.lastleg.fleet.trip.FleetDispatchGateway", - "reason": "仅复用端口定义的稳定卸货目的地匹配键,禁止放宽整个转运切片对车队实现的引用。" - }, - { - "source": "com.baiyue.lastleg.controller.LastlegOrderController", - "target": "com.baiyue.lastleg.relabel.dto.RelabelRequests", - "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" - }, - { - "source": "com.baiyue.lastleg.controller.LastlegOrderController", - "target": "com.baiyue.lastleg.relabel.dto.RelabelResponses", - "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" - }, - { - "source": "com.baiyue.lastleg.controller.LastlegOrderController", - "target": "com.baiyue.lastleg.relabel.service.RelabelApplicationService", - "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" - }, - { - "source": "com.baiyue.lastleg.discard.pda.controller.PdaCargoDiscardController", - "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", - "reason": "PDA废弃入口复用统一Token身份与绑定仓解析,不另建本地安全投影。" - }, - { - "source": "com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService", - "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", - "reason": "PDA废弃作业使用同一已验证身份范围。" - }, - { - "source": "com.baiyue.lastleg.transfer.controller.PdaWarehouseTransferController", - "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", - "reason": "转运PDA入口复用同一绑定仓身份解析。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlan", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", - "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", - "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" - }, - { - "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", - "target": "com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate", - "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" - }, - { - "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", - "target": "com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper", - "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.controller.PdaReturnReceiptController", - "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", - "reason": "退货签收照片复用当前业务文件网关。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", - "target": "com.baiyue.lastleg.relabel.entity.RelabelApplication", - "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", - "target": "com.baiyue.lastleg.relabel.entity.RelabelItem", - "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", - "target": "com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper", - "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", - "target": "com.baiyue.lastleg.relabel.mapper.RelabelItemMapper", - "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" - }, - { - "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", - "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", - "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" - } -], + { + "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "退货换标合箱沿用PDA冻结仓身份作为操作人与仓库约束,不引用入仓业务服务或持久化。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferReceiptCoordinator", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "转仓实收复用仓储资源HTTP网关,校验及预留目标托盘库位;资源权威仍在Warehouse,不读取入仓持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteModels", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteService", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsModels", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsService", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.service.InventoryQueryService", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressController", + "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", + "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverExport", + "reason": "统一导出装配器只调用自营周转的公开导出投影,复用固定版本查询;不开放自营Mapper或客户WMS库存访问,不产生反向依赖。" + }, + { + "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", + "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", + "reason": "费用适配器仅实现Fleet公司成本换算与Outbox端口,Fleet不读取费用模块或Outbox持久化实现;同事务发布且不新增package循环。" + }, + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", + "reason": "转运适配器实现外接来货端口,仅传登记与实测证据;车队不读取转运Mapper或库存表。" + }, + { + "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", + "target": "com.baiyue.lastleg.fleet.trip.FleetDispatchGateway", + "reason": "仅复用端口定义的稳定卸货目的地匹配键,禁止放宽整个转运切片对车队实现的引用。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelRequests", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.dto.RelabelResponses", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.controller.LastlegOrderController", + "target": "com.baiyue.lastleg.relabel.service.RelabelApplicationService", + "reason": "尾程订单直接换标审核入口委托既有换标服务和DTO,业务状态仍由换标服务维护。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.controller.PdaCargoDiscardController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃入口复用统一Token身份与绑定仓解析,不另建本地安全投影。" + }, + { + "source": "com.baiyue.lastleg.discard.pda.service.PdaCargoDiscardService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "PDA废弃作业使用同一已验证身份范围。" + }, + { + "source": "com.baiyue.lastleg.transfer.controller.PdaWarehouseTransferController", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "转运PDA入口复用同一绑定仓身份解析。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlan", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferService", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "仓储转运实收打托同步本服务入仓计划、分配和收货事实,资源仍经Warehouse网关;仅冻结这些既有引用,不开放其他转运类直接读取入仓实现。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter", + "target": "com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper", + "reason": "海外费用按业务月份读取当前正式月度汇率,缺失或未生效拒绝;仅此换算器可使用这两项现有实现引用。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.controller.PdaReturnReceiptController", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货签收照片复用当前业务文件网关。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelApplication", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.entity.RelabelItem", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.mapper.RelabelItemMapper", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService", + "target": "com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway", + "reason": "退货实收后按本客户可用退货箱创建换标申请及明细,照片复用原网关;保留同服务事务内稳定关联。" + }, + { + "source": "com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "换标托盘测量沿用PDA冻结仓身份限制原业务操作仓,不导入入仓服务或Mapper。" + }, + { + "source": "com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticController", + "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", + "reason": "内部只读数量诊断复用服务间身份守卫,不依赖客户库存与出库业务。" + } + ], "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -288,12 +300,21 @@ }, "sliceSpecificRules": { "fleet": { - "allowedOutgoing": ["overseas-master-data", "shared-technical"], - "allowedIncoming": ["dispatch-delivery"] + "allowedOutgoing": [ + "overseas-master-data", + "shared-technical" + ], + "allowedIncoming": [ + "dispatch-delivery" + ] }, "overseas-master-data": { - "allowedOutgoing": ["shared-technical"], - "allowedIncoming": ["fleet"] + "allowedOutgoing": [ + "shared-technical" + ], + "allowedIncoming": [ + "fleet" + ] } } } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 40c625e4a..eb901dbe1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -521,7 +521,9 @@ Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI' Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI' Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现' -Assert-Contains $terminalDrayageService 'context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现' +Assert-Contains $terminalDrayageService 'value.setEstimatedEmptyReturnAt(returnDeadline(context))' '码头拖车权威截止时间实现' +Assert-Contains $terminalDrayageService 'context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS))' '直送目的仓按绝对时长计算还空预警' +Assert-Contains $terminalDrayageService 'completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS)' '原海外仓卸柜截止时间实现' Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费' Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现' Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'