feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
@@ -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/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
+36
@@ -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,"内部身份无效");
|
||||
}
|
||||
}
|
||||
+29
@@ -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) { }
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+9
@@ -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
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+77
@@ -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);}
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
+15
@@ -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
|
||||
|
||||
+100
@@ -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);}
|
||||
}
|
||||
}
|
||||
+22
@@ -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,"无法核实已确认路线,请稍后重试");}
|
||||
}
|
||||
+77
@@ -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);}
|
||||
}
|
||||
+17
-5
@@ -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");
|
||||
|
||||
+29
-16
@@ -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);
|
||||
|
||||
+41
@@ -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'}
|
||||
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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))
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+22
@@ -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());
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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);
|
||||
|
||||
+41
@@ -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,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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))), []);
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public interface AppointmentBatchPreviewMapper {
|
||||
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixedDestinations,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
|
||||
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS activeAppointment
|
||||
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS activeAppointment
|
||||
FROM ll_order o WHERE o.id IN
|
||||
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
""" + "</script>")
|
||||
|
||||
+11
-6
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public interface AppointmentCargoMapper {
|
||||
g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan,
|
||||
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
|
||||
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
|
||||
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1 AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
|
||||
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
|
||||
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code<>o.fba_warehouse_code) AS mixed_destinations
|
||||
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+32
@@ -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()));
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+141
@@ -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);}
|
||||
}
|
||||
+9
-2
@@ -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);
|
||||
}
|
||||
|
||||
+20
-5
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+16
@@ -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("工作事项同步守卫未初始化");
|
||||
}
|
||||
}
|
||||
+161
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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()); }
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+112
@@ -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("内部服务身份未配置"); }
|
||||
}
|
||||
+43
@@ -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); }
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
+75
@@ -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";
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+164
@@ -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); }
|
||||
}
|
||||
+98
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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));
|
||||
}
|
||||
}
|
||||
+49
@@ -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<>();
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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.
|
||||
}
|
||||
}
|
||||
+27
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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 =
|
||||
|
||||
+11
-3
@@ -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())) {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+26
@@ -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());
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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())));
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+6
@@ -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) {
|
||||
|
||||
+26
@@ -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
Reference in New Issue
Block a user