feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
+2
View File
@@ -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/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
+50 -7
View File
@@ -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 '操作、操作者和请求摘要',
@@ -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;
+95 -6
View File
@@ -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;
+3 -1
View File
@@ -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` 发布应付 |
@@ -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` 只冻结这一精确集合,新增节点或第二个环仍失败,价格簿查询与客户统计仍必须处于所有环之外。合同有新增消费者与新增环的负向测试。预约新增提供端通过原域适配接口消费事实,不增加该包环。
@@ -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);
}
@@ -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', () => {
@@ -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> route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id);
}
@@ -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<SourcePage> 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<ContainerSource> 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<List<Fact>> facts(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestBody List<FactRequest> 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,"内部身份无效");
}
}
@@ -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<Reference> references, List<String> replacesRequirementKeys,
List<SourceReference> sources) { }
public record ContainerSource(String containerBookingId, String containerNo, Instant departedAt,
LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, String sourceVersion, String establishedFlow, String timezone, List<Requirement> requirements) { }
public record SourcePage(List<ContainerSource> 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<String> batchKeys, List<ContainerReference> containers,List<SourceReference> 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<String> readPermissions, List<String> actionPermissions, Action action, List<String> blockReasons,
List<String> missingFields, List<Reference> references, Responsibility responsibility) { }
}
@@ -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<Source> 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> cargo(@Param("id") long id);
}
@@ -15,6 +15,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@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<Long> selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to);
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
@@ -49,6 +49,9 @@ public class CarrierScheduleQueryRepository {
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
return queries.selectPickupCalendarContainers(from, to);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.util.*;
@Service
public class AppointmentWorkSourceService {
public interface OriginalFacts {
String establishedFlow(long containerBookingId);
Fact read(FactRequest request, ContainerSource source);
}
private final AppointmentWorkSourceMapper mapper;
private final OriginalFacts original;
private final ObjectMapper json;
public AppointmentWorkSourceService(AppointmentWorkSourceMapper mapper,OriginalFacts original,ObjectMapper json) {
this.mapper=mapper; this.original=original; this.json=json;
}
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public SourcePage discover(String after,int size) {
if(size<1 || size>200) throw bad("分页大小必须为1至200");
var rows=mapper.sources();
String scope=hash(rows); long last=0;
if(after!=null) {
String[] parts=after.split(":",-1);
if(parts.length!=2) throw bad("来源游标无效");
if(!scope.equals(parts[0])) throw new ResponseStatusException(HttpStatus.CONFLICT,"已开船来源已变化,请重新同步");
last=id(parts[1]);
}
final long cursor=last;
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
boolean complete=page.size()<=size;
var selected=page.stream().limit(size).toList();
return new SourcePage(selected.stream().map(this::source).toList(),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
}
public ContainerSource container(long id) {
return mapper.sources().stream().filter(row->row.id()==id).findFirst().map(this::source)
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次"));
}
private ContainerSource source(AppointmentWorkSourceMapper.Source row) {
var cargo=mapper.cargo(row.id());
var requirements=cargo.stream().map(c->new Requirement("INBOUND:"+c.inboundId(),null,null,
c.destinationKind(),row.departurePortName(),c.destinationName(),null,
List.of(new Reference("CUSTOMER",Long.toString(c.customerId()),null,"所属客户"),
new Reference("ORDER",Long.toString(c.orderId()),c.orderNo(),"原订单"),
new Reference("INBOUND",Long.toString(c.inboundId()),c.inboundCode(),"入仓单"),
new Reference("CONTAINER",Long.toString(row.id()),row.cabinetNo(),"柜次")),List.of(),List.of())).toList();
return new ContainerSource(Long.toString(row.id()),row.containerNo(),null,row.departedLocalAt(),row.sailedEvidenceLocalAt(),
hash(List.of(row,cargo)),original.establishedFlow(row.id()),null,requirements);
}
public List<Fact> facts(List<FactRequest> requests) {
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
Set<String> keys=new HashSet<>(); Map<Long,ContainerSource> sources=new HashMap<>();
return requests.stream().map(request->{
if(request==null || request.workItemKey()==null || request.workItemKey().isBlank() || !keys.add(request.workItemKey())
|| !("PICKUP".equals(request.type()) || "EMPTY_RETURN".equals(request.type()) || "DELIVERY".equals(request.type()) && "DIRECT".equals(request.flow())))
throw bad("事项身份或事实类型无效");
long id=id(request.containerBookingId());
return original.read(request,sources.computeIfAbsent(id,this::container));
}).toList();
}
public String hash(Object value) {
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException("不能生成来源版本",error);}
}
private static long id(String value) {
try {long id=Long.parseLong(value); if(id>0)return id;} catch(RuntimeException ignored) { }
throw bad("柜次或游标无效");
}
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
}
@@ -68,6 +68,7 @@ public class CarrierScheduleQueryService {
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
@Resource private ObjectMapper objectMapper;
@Resource private EmptyReturnPrerequisite emptyReturnPrerequisite;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
private Clock clock = Clock.systemDefaultZone();
@@ -81,6 +82,14 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages());
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)
|| java.time.Duration.between(from, to).compareTo(java.time.Duration.ofDays(42)) > 0) {
throw badRequest("提柜日历查询范围必须为1至42天");
}
return repository.pickupCalendarContainers(from, to);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
@@ -223,6 +232,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
requireEmptyReturnEvidence(value.getContainerBookingId(), node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -266,6 +276,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
requireEmptyReturnEvidence(containerBookingId, node, request.actualAt());
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
@@ -331,6 +342,11 @@ public class CarrierScheduleQueryService {
}
}
private void requireEmptyReturnEvidence(long containerBookingId,CarrierScheduleNode node,LocalDateTime actualAt) {
if(node==CarrierScheduleNode.EMPTY_RETURNED && actualAt!=null)
emptyReturnPrerequisite.requireUnloaded(containerBookingId,actualAt);
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.portschedule.service;
import java.time.LocalDateTime;
/** Actual unloading evidence owned by its original domain; not a work-item completion flag. */
public interface EmptyReturnPrerequisite {
LocalDateTime completedUnloading(long containerBookingId);
void requireUnloaded(long containerBookingId, LocalDateTime returnedAt);
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.DirectDeliveryService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries")
public class PlatformDirectDeliveryController {
private final DirectDeliveryService service;
public PlatformDirectDeliveryController(DirectDeliveryService service){this.service=service;}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<List<DirectDeliveryService.View>> list(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(containerBookingId));
}
@PostMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:direct-unload:confirm')")
public ApiResponse<DirectDeliveryService.View> record(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId,
@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody DirectDeliveryService.Request request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.record(containerBookingId,inboundId,user.userId(),user.username(),key,request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DirectDeliveryMapper {
record Delivery(long inboundId,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
String idempotencyKey,String requestHash) { }
record Container(long id,String recordStatus,LocalDateTime warehouseUnloadedAt) { }
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
Container lockContainer(@Param("id") long id);
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id}")
Container container(@Param("id") long id);
@Select("""
SELECT i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no,
MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count,
COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
GROUP BY i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
ORDER BY i.inbound_id
""")
List<Delivery> deliveries(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id}")
int evidenceCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id} AND idempotency_key=#{key}")
int keyCount(@Param("id") long id,@Param("key") String key);
@Insert("""
INSERT INTO fl_direct_delivery_unloading(container_booking_id,inbound_id,actual_at,timezone,received_by,
evidence_no,remark,version,idempotency_key,request_hash,created_by,created_by_name)
VALUES(#{id},#{inboundId},#{actualAt},#{timezone},#{receivedBy},#{evidenceNo},#{remark},1,#{key},#{hash},#{operatorId},#{operatorName})
""")
int insert(@Param("id") long id,@Param("inboundId") long inboundId,@Param("actualAt") LocalDateTime actualAt,
@Param("timezone") String timezone,@Param("receivedBy") String receivedBy,@Param("evidenceNo") String evidenceNo,
@Param("remark") String remark,@Param("key") String key,@Param("hash") String hash,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -27,6 +27,21 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendar(
@Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit);
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_container_booking c LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY c.id
</script>
""")
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendarByContainers(@Param("ids") List<Long> 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
@@ -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<Reference>();
source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);}));
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
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);}
}
}
@@ -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<AppointmentWorkRouteClient.Route> 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,"无法核实已确认路线,请稍后重试");}
}
@@ -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<View> 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);}
}
@@ -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<PickupCalendarEntry> 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<Long,PickupCalendarEntry>();
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<String,String> 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<PickupCalendarEntry>();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<PickupCalendarEntry> records,boolean truncated,int unknown) {
return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED");
@@ -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<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> 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);
@@ -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,"实际还柜时间不能早于卸货完成时间");
}
}
@@ -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='直送目的仓真实卸货凭据';
@@ -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:<physical inbound id>'}
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}}
@@ -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:<id>, 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}
@@ -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'}
@@ -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());
}
}
}
@@ -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());
}
}
}
@@ -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);
}
}
@@ -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))
@@ -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);
@@ -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();
}
}
}
@@ -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")<permissions.indexOf("INSERT INTO iam_permission"));
assertTrue(permissions.contains("platform:firstleg:terminal-drayage:direct-unload:confirm"));
assertTrue(permissions.contains("platform:appointment:manage-work"));
}
private String definition(String sql) {
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? fl_direct_delivery_unloading \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(match.find());String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(match.find());return result;
}
}
@@ -29,6 +29,15 @@ class TerminalDrayageCalendarMapperTest {
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
assertEquals(List.of(1L,2L),rows.stream().map(r->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());
}
}
}
@@ -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());
}
}
@@ -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);
@@ -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());
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -47,6 +47,7 @@ class TerminalDrayageServiceTest {
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
private TerminalDrayageMailService mails;
private UnloadingEvidenceGuard unloadingEvidence;
private Deque<ContainerActualFacts> 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);
@@ -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));
}
}
@@ -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,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。"
}
]
}
+1
View File
@@ -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/` |
@@ -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<PlatformOwner> 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));
}
}
@@ -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) { }
@@ -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,
@@ -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;
}
}
@@ -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;
@@ -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 / 商户企业]
@@ -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());
}
}
@@ -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));
}
}
}
@@ -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<String> serviceCatalog = new LinkedHashSet<>();
serviceCatalog.addAll(codes(repositoryFile(
"lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java")));
@@ -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))), []);
@@ -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&lt;&gt;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
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
""" + "</script>")
@@ -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<AppointmentSourceService.Source> sources,
List<String> missingFields) { }
List<String> missingFields,List<AppointmentCargoVerificationService.Context> cargoVerification) { }
public Preview preview(List<AppointmentCoverageService.SourceRef> 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.<Long,AppointmentBatchPreviewMapper.Flags>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);
}
@@ -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&lt;&gt;o.fba_warehouse_code) AS mixed_destinations
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
@@ -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;
}
@@ -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<AppointmentCargoVerificationService.Context> 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<AppointmentCargoVerificationService.Verification> 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()));
}
}
@@ -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<AppointmentCargoVerification> {
/** 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);
}
@@ -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<String> 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<String> allowedActions,
List<String> 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.<String>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<allocation.packages())) throw conflict("核实箱数不能低于已有预约分配总量");
var row=new AppointmentCargoVerification(); row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setSourceKey(sourceKey);
row.setDestinationKey(source.destinationKey());row.setSourceVersion(source.sourceVersion());row.setSourcePackages(source.packages());
row.setVersion(request.expectedVersion()+1);row.setPackageCount(count);
row.setShipmentReferencesJson(encode(request.shipmentReferences().stream().map(String::trim).distinct().toList()));
row.setRemark(clean(request.remark()));row.setOperatorId(operatorId);row.setOperatorName(clean(operatorName));
row.setCommandKey(request.idempotencyKey());row.setRequestHash(hash);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS));
try { mapper.insert(row); }
catch(DuplicateKeyException error) {
var raced=mapper.command(customerId,sourceKey,request.idempotencyKey());
if(raced!=null) return replay(raced,hash);
throw conflict("核实资料版本已更新,请刷新后提交");
}
return view(row);
}
/** Caller already holds the original source lock when using this evidence to allocate or confirm. */
public Verification current(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey());
if(row!=null && changed(row,source)) throw conflict("原来源已变化,请重新核实预约货物资料");
return row==null?null:view(row);
}
private static boolean changed(AppointmentCargoVerification row,AppointmentSourceService.Source source) {
return !Objects.equals(row.getSourceVersion(),source.sourceVersion()) || !Objects.equals(row.getDestinationKey(),source.destinationKey())
|| !Objects.equals(row.getSourcePackages(),source.packages());
}
private Verification replay(AppointmentCargoVerification row,String hash) {
if(!Objects.equals(row.getRequestHash(),hash)) throw conflict("同一保存请求的内容或操作人不一致,请核对核实记录");
return view(row);
}
private Verification view(AppointmentCargoVerification row) {
try {
return new Verification(row.getVersion(),row.getSourceVersion(),row.getDestinationKey(),row.getSourcePackages(),
row.getPackageCount(),json.readValue(row.getShipmentReferencesJson(),new TypeReference<List<String>>(){}),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);}
}
@@ -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({"<script>SELECT o.id AS orderId,o.customer_id AS customerId,"+DESTINATION_TYPE+" AS destinationType,",
DESTINATION_CODE+" AS fbaWarehouseCode,o.package_count AS packages,o.version,o.updated_at AS updatedAt, ",
@@ -31,11 +31,18 @@ public interface AppointmentCoverageMapper {
@Select({"<script>SELECT i.order_id AS orderId,i.customer_id AS customerId,a.appointment_no AS appointmentNo,",
"a.appointment_status AS status,a.appointment_at AS appointmentAt,a.appointment_end_at AS appointmentEndAt,",
"a.updated_at AS updatedAt,i.id AS itemId,i.package_count AS packages,i.fba_warehouse_code AS warehouseCode, ",
"i.source_key AS sourceKey,i.source_version AS sourceVersion,a.confirmation_no AS confirmationNo,a.timezone ",
"i.source_key AS sourceKey,i.source_version AS sourceVersion,a.confirmation_no AS confirmationNo,a.timezone,i.cargo_verification_version AS cargoVerificationVersion ",
"FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id ",
"WHERE i.active=1 AND a.appointment_status NOT IN ('FAILED','CANCELLED') AND ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(i.source_key=#{s.sourceKey} AND i.customer_id=#{s.customerId})</foreach>",
" ORDER BY a.appointment_at,a.id,i.id</script>"})
List<Fact> appointments(@Param("sources") List<AppointmentCoverageService.SourceRef> sources);
@Select({"<script>SELECT v.* FROM ll_appointment_cargo_verification v WHERE ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(v.source_key=#{s.sourceKey} AND v.customer_id=#{s.customerId})</foreach>",
" AND v.version=(SELECT MAX(latest.version) FROM ll_appointment_cargo_verification latest",
" WHERE latest.source_key=v.source_key AND latest.customer_id=v.customer_id)</script>"})
List<AppointmentCargoVerification> verifications(@Param("sources") List<AppointmentCoverageService.SourceRef> sources);
}
@@ -49,6 +49,7 @@ public class AppointmentCoverageService {
var orders=unique.isEmpty() ? List.<AppointmentCoverageMapper.Source>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<Coverage>();
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<String,Appointment>();
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>(); 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>();
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<order.packages()) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
else if (confirmed<order.packages()) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else if (covered<packages) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else { status="CONFIRMED"; }
if (relevant.stream().anyMatch(f->"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);}
}
}
@@ -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<Resolved> 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<Resolved> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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("工作事项同步守卫未初始化");
}
}
@@ -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>(); 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<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
var states=store.all(); var keys=new LinkedHashSet<String>(); 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<String>();
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<String> allowed,Set<String> required) {
if(node==null || !node.isObject()) throw bad("命令payload须为对象");
var names=new HashSet<String>(); 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<String,String>(); node.fields().forEachRemaining(e->parts.put(e.getKey(),canonical(e.getValue()))); return parts.toString(); }
if(node.isArray()) { var parts=new ArrayList<String>(); node.forEach(v->parts.add(canonical(v))); return parts.toString(); }
return node.toString();
}
}
@@ -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());
}
}
@@ -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<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set<Type> types) {
return ApiResponse.success(queries.calendar(from,to,flow,types,actor(user)));
}
@GetMapping("/summary")
public ApiResponse<Summary> summary(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(queries.summary(actor(user))); }
@GetMapping("/items")
public ApiResponse<PageResult<WorkItem>> 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<Type> 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<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
}
@PostMapping("/items/{workItemKey}/commands")
public ApiResponse<CommandResult> 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());
}
}
@@ -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<Fact> read(List<FactRequest> requests) {
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var input=new ArrayList<FactRequest>();
for(var r:requests) { input.add(request(r,Type.PICKUP,"/carrier")); input.add(request(r,Type.DELIVERY,"/delivery")); }
var facts=new HashMap<String,Fact>();
for(int start=0;start<input.size();start+=200) {
var batch=input.subList(start,Math.min(start+200,input.size())); var response=firstleg.facts(token,batch);
if(response==null || !response.successful()) throw unavailable("直送承运及卸货事实不可用");
AppointmentWorkFactAdapter.append(facts,batch,response.data());
}
return requests.stream().map(r->{
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()); }
}
@@ -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<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -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<BusinessProvider> providers;
private final ObjectProvider<OwnerDirectory> directory;
private final ObjectProvider<RequirementProvider> requirements;
private final ObjectProvider<SupplierDirectory> suppliers;
private final String token;
public AppointmentWorkFactAdapter(AppointmentWorkFirstlegClient firstleg, ObjectProvider<BusinessProvider> providers,
ObjectProvider<OwnerDirectory> directory, ObjectProvider<RequirementProvider> requirements, ObjectProvider<SupplierDirectory> 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<String,Fact> facts(List<AppointmentWorkState> states) {
var result=new LinkedHashMap<String,Fact>();
var remote=new ArrayList<FactRequest>();
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
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<remote.size(); start+=200) {
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
try { var response=firstleg.facts(token,requests);
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
append(result,requests,response.data());
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
}
groups.forEach((provider,requests)->{
for(int start=0; start<requests.size(); start+=200) {
var batch=requests.subList(start,Math.min(start+200,requests.size()));
try { append(result,batch,provider.read(batch)); }
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
}
});
return result;
}
public List<Requirement> 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<String,Fact> target,List<FactRequest> requests,List<Fact> 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("内部服务身份未配置"); }
}
@@ -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<BusinessReference> references,
List<String> replacesRequirementKeys, List<SourceReference> sources) { }
record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, java.time.LocalDateTime departedLocalAt, java.time.LocalDateTime sailedEvidenceLocalAt,
String sourceVersion, Flow establishedFlow, String timezone, List<Requirement> requirements) { }
record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
record FactRequest(String workItemKey, Type type, Flow flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,
List<SourceReference> 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<String> readPermissions, List<String> actionPermissions,
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> 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<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
Map<String, Fact> facts(List<AppointmentWorkState> 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<Fact> read(List<FactRequest> 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<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
}
@@ -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<SourcePage> 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<ContainerSource> container(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("id") String id);
@PostMapping("/internal/firstleg/appointment-work/facts")
ApiResponse<List<Fact>> facts(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody List<FactRequest> requests);
}
@@ -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<AppointmentWorkRouteService.Route> 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<SyncResult> synchronize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody SyncRequest request) {
guard.verify(token); return ApiResponse.success(sync.synchronize(request));
}
}
@@ -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<Row> all(int limit);
@Select("SELECT * FROM ll_appointment_work WHERE container_booking_id=#{id} ORDER BY work_item_key") List<Row> 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<History> selectHistory(@Param("key") String key);
}
@@ -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<SourceReference> sources) implements ActionTarget { }
public record ContainerTarget(String containerBookingId) implements ActionTarget { }
public record TripTarget(String tripId,List<String> batchKeys) implements ActionTarget { }
public record ReceiptTarget(String inboundPlanId,String inboundPlanNo,String customerId,String containerBookingId) implements ActionTarget { }
public record ReadTarget(List<BusinessReference> 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<ContainerReference> 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<String> overdueReasons,
List<Action> allowedActions, Action nextAction, List<String> blockReasons, List<String> 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<WorkItem> relatedItems,
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
public record Review(int version,List<String> 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<Type, Long> typeCounts, BucketCounts counts) { }
public record CalendarResult(LocalDate from, LocalDate to, long total, BucketCounts counts, List<DayAggregate> 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<String> 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<String> 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";
}
@@ -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<AppointmentWorkModels.Owner> active(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("authUserId") String id);
}
@@ -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;
}
}
@@ -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<String>(); var missing=new ArrayList<String>();
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<String>();
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<Action>();
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<WorkItem> items) {
var counts=new EnumMap<Bucket,Long>(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<String> effectiveKeys(Collection<String> keys,Map<String,AppointmentWorkState> states) {
var pending=new ArrayDeque<>(keys); var seen=new HashSet<String>(); var effective=new LinkedHashSet<String>();
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); }
}
@@ -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<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> 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<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> 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<Type> 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<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> 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());
}
}
@@ -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));
}
}
@@ -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<String> batchKeys = new ArrayList<>();
private List<ContainerReference> 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<String> replacementKeys = new ArrayList<>();
private List<String> prerequisites = new ArrayList<>();
private List<BusinessReference> references = new ArrayList<>();
private List<SourceReference> sources = new ArrayList<>();
private List<String> readPermissions = new ArrayList<>();
}
@@ -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<AppointmentWorkState> all() {
var rows=mapper.all(MAX_ITEMS+1);
if(rows.size()>MAX_ITEMS) throw conflict("工作事项超过当前全量核对上限,不能返回截断统计");
return rows.stream().map(this::decode).toList();
}
public List<AppointmentWorkState> 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<HistoryEntry> 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> T read(String value,Class<T> 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;
}
}
@@ -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<String>();
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<String>(); var batches=new HashSet<String>();
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<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> 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<AppointmentWorkState>();
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<String>(); 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<String,AppointmentWorkState> known,List<AppointmentWorkState> 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<AppointmentWorkState> states,Map<String,Fact> facts,Actor actor) {
var affected=new LinkedHashSet<String>();
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<AppointmentWorkState> states,Set<String> affected) {
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
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<AppointmentWorkState> all,Map<String,Fact> 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<String>();
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.
}
}
@@ -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());
}
}
}
@@ -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<Long> planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List<Long> returnReceiptIds) {
this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of());
}
public PreArrange(List<Long> 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<Long> pallets,List<Long> receipts) { this(id,pallets,receipts,List.of()); }
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
@@ -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 =
@@ -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<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>();
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<DispatchItem> 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())) {
@@ -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("车次状态已变化");
@@ -7,7 +7,14 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
@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<FleetTripEntity> 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);
@@ -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
@@ -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());
}
}
@@ -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<Fact> read(List<FactRequest> 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.<String>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);
}
}
@@ -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())));
}
}
@@ -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<FleetTripEntity> 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<String> batches(long id);
}
@@ -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<RepartitionFbaAppointmentsResponse> 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<PageResult<FbaAppointmentResponse>> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
@@ -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<FbaAppointmentBatchContext> 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));
}
}

Some files were not shown because too many files have changed in this diff Show More