This commit is contained in:
wujinliang
2026-09-18 11:41:13 +08:00
68 changed files with 819 additions and 383 deletions
@@ -50,7 +50,7 @@ class OverseasTokenServiceTest {
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
String authority = "platform:overseas-master-data:quote:list";
String merchantAuthority = "merchant:warehouse:delivery:list";
String merchantCrossBorderAuthority = "merchant:cross-border:procurement";
String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list";
String lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list";
+1 -1
View File
@@ -40,7 +40,7 @@
| 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 |
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -1438,7 +1438,7 @@ CREATE TABLE fl_consumed_event (
CREATE TABLE fl_container_booking (
id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '集装箱号;确认前允许修订',
container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订',
cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -1494,7 +1494,8 @@ CREATE TABLE fl_container_booking (
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_container_no (container_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
KEY idx_container_booking_merge (record_status,merged_into_container_booking_id),
KEY idx_container_booking_warehouse (warehouse_id,updated_at,id),
KEY idx_container_booking_voyage (voyage_no,updated_at),
@@ -3368,6 +3369,7 @@ CREATE TABLE fl_ocean_booking (
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
@@ -5966,7 +5968,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at)
-- 系统基线 iam_number_sequence:1 行。
INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3));
-- 系统基线 iam_permission:346 行。
-- 系统基线 iam_permission:364 行。
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 (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6307,12 +6309,30 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
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 (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306141,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',96,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
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 (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
-- 系统基线 iam_role:8 行。
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000001,0,'PLATFORM','PLATFORM_ADMIN','平台超级管理员','SYSTEM','ACTIVE','一期平台全部菜单和按钮',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
@@ -6324,7 +6344,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type,
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0);
-- 系统基线 iam_role_permission:521 行。
-- 系统基线 iam_role_permission:539 行。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3));
@@ -6841,11 +6861,29 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306111,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306112,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306120,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306121,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306122,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306130,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306140,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306141,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306150,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306160,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306161,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306162,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306163,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306170,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306171,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
-- 系统基线 iam_tenant:1 行。
INSERT INTO iam_tenant (id, tenant_code, tenant_name, customer_nickname, tenant_type, status, dimension_initialization_status, legal_name, legal_representative, unified_social_credit_code, contact_name, contact_phone, contact_email, service_package, contact_wechat, sales_owner_name, payment_term_days, enterprise_type, established_date, registered_capital, registered_capital_currency, registered_address, internal_remark, approved_at, approved_by, reject_reason, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (0,'PLATFORM','虫虫出海平台','平台','PLATFORM','ACTIVE','READY','虫虫出海平台',NULL,NULL,'平台系统管理员',NULL,NULL,'基础版',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),1000001,NULL,1000001,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
+1 -1
View File
@@ -73,7 +73,7 @@
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
@@ -168,9 +168,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
@Update("""
UPDATE fl_container_booking SET unloading_completed_at=#{unloadingCompletedAt},
unloading_photo_uploaded_at=#{photoUploadedAt}, estimated_empty_reported_at=#{estimatedEmptyReportedAt},
@@ -39,7 +39,7 @@ public final class OceanBookingDtos {
}
}
public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId,
public record LinkCandidate(Long oceanBookingId, String bookingNo, String billOfLadingNo, Long requirementId,
String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg,
int requiredQuantity, long allocatedQuantity, long remainingQuantity,
String customerName, String warehouseName, String channelName) {}
@@ -55,7 +55,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Select("""
<script>
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
SELECT booking.id ocean_booking_id,booking.booking_no,booking.bill_of_lading_no,requirement.id requirement_id,
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
requirement.quantity required_quantity,
COALESCE(allocated.allocated_quantity,0) allocated_quantity,
@@ -38,6 +38,12 @@ public class OceanBookingRepository {
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
public boolean billOfLadingNoExists(String billOfLadingNo, Long excludedId) {
if (billOfLadingNo == null) return false;
return bookings.selectCount(new LambdaQueryWrapper<OceanBookingEntity>()
.eq(OceanBookingEntity::getBillOfLadingNo, billOfLadingNo)
.ne(excludedId != null, OceanBookingEntity::getId, excludedId)) > 0;
}
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, operatorName);
@@ -102,9 +108,6 @@ public class OceanBookingRepository {
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public boolean containerNoExists(String containerNo) {
return containerBookings.containerNoExists(containerNo, null);
}
public void insertContainer(ContainerBookingEntity value) { containerBookings.insert(value); }
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
public int reassignAllocation(long sourceId, long targetId) {
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import feign.FeignException;
@@ -110,9 +111,11 @@ public class OceanBookingService {
value.setBookingNo(documentNumbers.next("OB"));
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), resolved);
requireUniqueBillOfLadingNo(value.getBillOfLadingNo(), null);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
repository.insert(value);
try { repository.insert(value); }
catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
List<OceanBookingContainerRequirementEntity> requirements = requirements(
value.getId(), request.data().containerRequirements());
@@ -132,7 +135,10 @@ public class OceanBookingService {
validateDraftStructure(request.data());
ResolvedDraft resolved = resolveDraft(request.data());
copy(before, request.data(), resolved);
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
requireUniqueBillOfLadingNo(before.getBillOfLadingNo(), id);
try {
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
} catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
repository.replaceParties(id, parties(id, request.data().parties()));
repository.replaceRequirements(id, requirements(id, request.data().containerRequirements()));
OceanBookingEntity after = required(id);
@@ -294,6 +300,14 @@ public class OceanBookingService {
v.setRemark(trim(d.remark()));
}
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
if (repository.billOfLadingNoExists(billOfLadingNo, excludedId)) throw duplicateBillOfLadingNo();
}
private static BizException duplicateBillOfLadingNo() {
return conflict("提单号已存在,请检查是否重复创建订舱");
}
private void validateMasterData(OceanBookingEntity booking) {
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
@@ -22,9 +22,10 @@ public class ContainerRepository {
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
public boolean containerNoExists(String no, Long excludedId) {
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getOceanBookingId, oceanBookingId)
.eq(ContainerBookingEntity::getContainerNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
@@ -93,7 +93,9 @@ public class ContainerService {
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (containerNo != null && repository.containerNoExists(request.oceanBookingId(), containerNo, id)) {
throw conflict("当前提单下集装箱号已存在");
}
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
@@ -105,7 +107,7 @@ public class ContainerService {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
return required(id);
@@ -126,13 +128,15 @@ public class ContainerService {
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
}
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
throw conflict("当前提单下集装箱号已存在");
}
try {
if (repository.assignRealContainerNo(id, containerNo, expectedVersion, operatorId, operatorName) != 1) {
throw conflict("真实柜号已被补录或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
return required(id);
}
@@ -463,9 +463,10 @@ public class LoadingListService {
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerNo != null
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("集装箱号已存在");
if (containerNo != null && before.getOceanBookingId() != null
&& containerRepository.containerNoExists(before.getOceanBookingId(), containerNo,
before.getContainerBookingId())) {
throw conflict("当前提单下集装箱号已存在");
}
try {
int updated = initialContainerFill
@@ -477,7 +478,7 @@ public class LoadingListService {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
@@ -52,6 +53,18 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
SET @ocean_add_bill_unique = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE fl_ocean_booking ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)',
'SELECT 1')
FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
AND index_name='uk_ocean_booking_bill_of_lading'
);
PREPARE ocean_bill_unique_stmt FROM @ocean_add_bill_unique;
EXECUTE ocean_bill_unique_stmt;
DEALLOCATE PREPARE ocean_bill_unique_stmt;
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -92,7 +92,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_container_no (container_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
KEY idx_container_booking_merge (record_status,merged_into_container_booking_id),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
@@ -203,9 +204,17 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
ALTER TABLE fl_container_booking DROP INDEX uk_container_booking_container_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_ocean_container') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='idx_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_container_no (container_no,updated_at,id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN
@@ -457,7 +457,7 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别、校验位和全局唯一性。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -2764,7 +2764,7 @@ components:
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和全局唯一性}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest:
type: object
@@ -167,7 +167,7 @@ components:
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码}
warehouseId: {type: integer, format: int64, nullable: true}
billOfLadingNo: {type: string, nullable: true}
billOfLadingNo: {type: string, nullable: true, description: 全局唯一提单号;草稿未取得时可为空}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
carrierCode:
@@ -267,10 +267,11 @@ components:
carrierCodes: {type: array, items: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}}
LinkCandidate:
type: object
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
required: [oceanBookingId, bookingNo, billOfLadingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
properties:
oceanBookingId: {type: string, pattern: '^[0-9]+$'}
bookingNo: {type: string}
billOfLadingNo: {type: string, description: 已发布订舱的全局唯一提单号}
requirementId: {type: string, pattern: '^[0-9]+$'}
containerType: {type: string}
capacityCbm: {type: number}
@@ -106,7 +106,7 @@ class ContainerControllerContractTest {
.contains("未满配 requirementId")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -52,6 +52,7 @@ class OceanBookingMapperContractTest {
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("booking.bill_of_lading_no")
.contains("booking.status='RELEASED'")
.contains("booking.warehouse_id=#{query.warehouseId}")
.contains("booking.channel_code=#{query.channelGroupCode}")
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -23,6 +25,8 @@ class OceanBookingSqlContractTest {
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.doesNotContain("service_term")
.doesNotContain("freight_term")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
@@ -33,6 +37,14 @@ class OceanBookingSqlContractTest {
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void billOfLadingNumberIsUniqueInModuleAndUnifiedInitialization() throws Exception {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for (String source : new String[] {sql(), unified}) {
assertThat(source).contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)");
}
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
@@ -326,7 +326,7 @@ class OceanBookingServiceTest {
@Test
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ",
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", "BL-001", 201L, "40HQ",
new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2,
"客户A", "深圳中转仓", "美森正班");
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
@@ -347,6 +347,18 @@ class OceanBookingServiceTest {
verify(repository, never()).containers(anyLong());
}
@Test
void rejectsDuplicateBillOfLadingNumberBeforeCreatingDraft() {
stubMasterData();
when(repository.billOfLadingNoExists("BL-001", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-bill",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessageContaining("提单号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -106,16 +106,18 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(module.toLowerCase())
.contains("国内唯一权威真实集装箱号")
.contains("国内柜次真实集装箱号权威快照")
.contains("同一订舱内唯一,不同提单允许复用")
.contains("提柜后可原子补录");
for (String source : new String[] {module, unified}) {
assertThat(source.toLowerCase())
.contains("unique key uk_container_booking_container_no (container_no)")
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
.contains("constraint chk_container_booking_iso6346")
.contains("^[a-z]{3}[ujz][0-9]{7}$");
}
@@ -68,7 +68,8 @@ class SmartLoadingSqlContractTest {
.contains("source_type varchar(24) NOT NULL DEFAULT 'SIMULATION'")
.contains("record_status varchar(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("merged_into_container_booking_id bigint unsigned DEFAULT NULL")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("KEY idx_container_booking_container_no (container_no,updated_at,id)")
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
.contains("CREATE TRIGGER trg_loading_plan_before_update")
.contains("装柜状态只允许单向推进")
@@ -246,7 +246,7 @@ class ContainerServiceTest {
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyString(), anyLong());
verify(repository, never()).containerNoExists(anyLong(), anyString(), anyLong());
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
@@ -303,12 +303,12 @@ class ContainerServiceTest {
}
@Test
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
@DisplayName("提柜补录真实柜号拒绝同一订舱内重复号")
void rejectsDuplicateRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked);
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
@@ -331,10 +331,10 @@ class ContainerServiceTest {
}
@Test
@DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突")
@DisplayName("首次关联规范化后按同一订舱唯一真实柜号拒绝冲突")
void rejectsNormalizedContainerNumberConflict() {
stubLinkContext();
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
@@ -276,19 +276,19 @@ class LoadingListServiceTest {
}
@Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
void rejectsSameBookingContainerConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false);
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(false);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no"));
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_ocean_container"));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
@@ -961,6 +961,7 @@ class LoadingListServiceTest {
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setChannelCode("OA_LA_TRUCK");
row.setOceanBookingId(90L);
row.setOutboundConfirmedAt(java.time.LocalDateTime.now());
row.setVersion(version);
return row;
+1 -1
View File
@@ -18,7 +18,7 @@
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 |
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 |
| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg |
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
@@ -13,7 +13,7 @@ spring:
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"]
- id: warehouse-overseas-service
uri: lb://warehouse-overseas-service
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
- id: file-service
uri: lb://file-service
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
@@ -78,11 +78,30 @@ VALUES
(5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0),
(5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0),
(5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0),
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0),
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0),
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0),
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,0),
(5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,0),
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,0),
(5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,0),
(5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,0),
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,0),
(5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,0),
(5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,0),
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0),
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0)
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0),
(5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,0),
(5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,0),
(5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,0),
(5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,0),
(5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,0),
(5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,0),
(5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,0),
(5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,0),
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
@@ -225,11 +244,30 @@ SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
AND permission.permission_code IN (
'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping',
'merchant:cross-border:procurement:list',
'merchant:cross-border:procurement:create',
'merchant:cross-border:domestic-trucking:list',
'merchant:cross-border:domestic-trucking:create',
'merchant:cross-border:domestic-trucking:edit',
'merchant:cross-border:pallet-shipping:list',
'merchant:cross-border:pallet-shipping:create',
'merchant:cross-border:pallet-shipping:edit',
'merchant:warehouse:inbound:list',
'merchant:overseas-warehouse:return-inventory:list'
'merchant:overseas-warehouse:return-inventory:list',
'merchant:overseas-warehouse:last-mile-order:list',
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:relabel:create',
'merchant:overseas-warehouse:relabel:files',
'merchant:overseas-warehouse:relabel:confirm',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:transfer:create',
'merchant:overseas-warehouse:transfer:confirm',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel'
)
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -241,11 +279,30 @@ BEGIN
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND deleted=0
AND permission_code IN (
'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping',
'merchant:cross-border:procurement:list',
'merchant:cross-border:procurement:create',
'merchant:cross-border:domestic-trucking:list',
'merchant:cross-border:domestic-trucking:create',
'merchant:cross-border:domestic-trucking:edit',
'merchant:cross-border:pallet-shipping:list',
'merchant:cross-border:pallet-shipping:create',
'merchant:cross-border:pallet-shipping:edit',
'merchant:warehouse:inbound:list',
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
'merchant:overseas-warehouse:return-inventory:list',
'merchant:overseas-warehouse:last-mile-order:list',
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:relabel:create',
'merchant:overseas-warehouse:relabel:files',
'merchant:overseas-warehouse:relabel:confirm',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:transfer:create',
'merchant:overseas-warehouse:transfer:confirm',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -256,11 +313,30 @@ BEGIN
AND permission.tenant_id=0 AND permission.app_code='MERCHANT'
AND permission.status='ACTIVE' AND permission.deleted=0
AND permission.permission_code IN (
'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping',
'merchant:cross-border:procurement:list',
'merchant:cross-border:procurement:create',
'merchant:cross-border:domestic-trucking:list',
'merchant:cross-border:domestic-trucking:create',
'merchant:cross-border:domestic-trucking:edit',
'merchant:cross-border:pallet-shipping:list',
'merchant:cross-border:pallet-shipping:create',
'merchant:cross-border:pallet-shipping:edit',
'merchant:warehouse:inbound:list',
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
'merchant:overseas-warehouse:return-inventory:list',
'merchant:overseas-warehouse:last-mile-order:list',
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:relabel:create',
'merchant:overseas-warehouse:relabel:files',
'merchant:overseas-warehouse:relabel:confirm',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:transfer:create',
'merchant:overseas-warehouse:transfer:confirm',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
END IF;
END$$
+1 -1
View File
@@ -342,7 +342,7 @@ paths:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限
description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。
description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。
responses:
'200':
description: 当前商户成员权限查询成功
@@ -46,9 +46,9 @@ class IamOpenApiContractTest {
.contains("不以 `container:list/edit` 兼容放行国外操作")
.contains("merchant:warehouse:delivery:list")
.contains("merchant:warehouse:delivery:create")
.contains("merchant:cross-border:procurement")
.contains("merchant:cross-border:domestic-trucking")
.contains("merchant:cross-border:pallet-shipping")
.contains("merchant:cross-border:procurement:list")
.contains("merchant:cross-border:domestic-trucking:list")
.contains("merchant:cross-border:pallet-shipping:list")
.contains("merchant:warehouse:inbound:list")
.contains("lastleg:pda:operate")
.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR")
@@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest {
assertThat(sql).contains(
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
"role.role_code='MERCHANT_OWNER'",
"'" + PERMISSION + "')) <> 5",
")) <> 24 THEN",
"客户默认管理员授权不完整");
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
@@ -15,30 +15,48 @@ import static org.assertj.core.api.Assertions.assertThat;
class OverseasCustomerPermissionSqlContractTest {
private static final Pattern MERCHANT_CODE = Pattern.compile("merchant:[a-z0-9:-]+");
private static final Set<String> CUSTOMER_OVERSEAS_PERMISSIONS = Set.of(
"merchant:cross-border:procurement",
"merchant:cross-border:domestic-trucking",
"merchant:cross-border:pallet-shipping",
"merchant:cross-border:procurement:list",
"merchant:cross-border:procurement:create",
"merchant:cross-border:domestic-trucking:list",
"merchant:cross-border:domestic-trucking:create",
"merchant:cross-border:domestic-trucking:edit",
"merchant:cross-border:pallet-shipping:list",
"merchant:cross-border:pallet-shipping:create",
"merchant:cross-border:pallet-shipping:edit",
"merchant:warehouse:inbound:list",
"merchant:overseas-warehouse:return-inventory:list");
"merchant:overseas-warehouse:return-inventory:list",
"merchant:overseas-warehouse:last-mile-order:list",
"merchant:overseas-warehouse:relabel:list",
"merchant:overseas-warehouse:relabel:create",
"merchant:overseas-warehouse:relabel:files",
"merchant:overseas-warehouse:relabel:confirm",
"merchant:overseas-warehouse:transfer:list",
"merchant:overseas-warehouse:transfer:create",
"merchant:overseas-warehouse:transfer:confirm",
"merchant:overseas-warehouse:discard:list",
"merchant:overseas-warehouse:discard:create",
"merchant:overseas-warehouse:discard:cancel",
"merchant:overseas-warehouse:return-order:list",
"merchant:overseas-warehouse:return-order:create",
"merchant:overseas-warehouse:return-order:relabel");
@Test
void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception {
String sql = sql();
assertThat(merchantCodes(sql)).containsExactlyInAnyOrderElementsOf(CUSTOMER_OVERSEAS_PERMISSIONS);
for (String permission : CUSTOMER_OVERSEAS_PERMISSIONS) {
assertThat(sql).contains("0,'MERCHANT','MENU'", "'" + permission + "'");
}
assertThat(sql).contains(
"'merchant:cross-border:procurement','海外采购','/cross-border/procurement'",
"'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'",
"'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'",
"'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement'",
"'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking'",
"'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping'",
"'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'",
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'");
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'",
"'merchant:overseas-warehouse:relabel:files','管理贴标文件'",
"'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请'");
}
@Test
void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
void grantsAllPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
String sql = sql();
int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment);
@@ -66,7 +84,7 @@ class OverseasCustomerPermissionSqlContractTest {
"海外客户权限初始化失败:客户默认管理员授权不完整",
"permission.app_code='MERCHANT'",
"role.role_code='MERCHANT_OWNER'",
")) <> 5 THEN");
")) <> 24 THEN");
}
@Test
@@ -95,18 +95,20 @@ class AuthorizationSnapshotServiceTest {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
"merchant:cross-border:procurement",
"merchant:cross-border:domestic-trucking",
"merchant:cross-border:pallet-shipping",
"merchant:cross-border:procurement:list",
"merchant:cross-border:procurement:create",
"merchant:cross-border:domestic-trucking:list",
"merchant:cross-border:pallet-shipping:list",
"merchant:warehouse:inbound:list"));
var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
assertThat(result).containsExactlyInAnyOrder(
"merchant:cross-border:procurement",
"merchant:cross-border:domestic-trucking",
"merchant:cross-border:pallet-shipping",
"merchant:cross-border:procurement:list",
"merchant:cross-border:procurement:create",
"merchant:cross-border:domestic-trucking:list",
"merchant:cross-border:pallet-shipping:list",
"merchant:warehouse:inbound:list");
}
+4 -4
View File
@@ -27,7 +27,7 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -61,11 +61,11 @@
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
@@ -179,7 +179,8 @@ public class LastlegOrderController {
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "暂扣或解除单票尾程订单",
description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
description = "要求版本与幂等键;新增暂扣只允许完成入库且尚未进入出库流程的订单,"
+ "暂扣只作用于该票并仅阻断排车和出库。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> hold(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -7,9 +7,11 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -19,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST + "')")
public class MerchantLastlegOrderController {
@Resource private LastlegOrderService orderService;
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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;
@@ -19,18 +21,23 @@ import java.util.List;
public class MerchantCargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{discardNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
@PostMapping("/{discardNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); }
private String customer(AuthenticatedUser user) {
@@ -114,12 +114,6 @@ public final class LastlegOrderRequests {
) {
}
public record SetDetained(
@NotNull Boolean detained,
@NotBlank @Size(max = 500) String remark
) {
}
public record SetPalletMergeRequired(
@NotNull Boolean palletMergeRequired
) {
@@ -126,7 +126,7 @@ public interface InboundPlanOrderMapper {
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," +
"pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " +
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} " +
"AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL")
int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
@@ -23,7 +24,8 @@ public final class ReceivingContainerRequests {
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, Integer expectedPalletVersion,
@Positive @Max(value = 66, message = "一个托盘最多只能收66箱,请拆分到其他托盘") int packageCount,
Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
@@ -73,6 +73,9 @@ public class ReceivingContainerService {
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
private static final int MAX_PACKAGES_PER_PALLET = 66;
private static final String PALLET_CAPACITY_MESSAGE =
"一个托盘最多只能收66箱,请拆分到其他托盘";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@@ -364,6 +367,7 @@ public class ReceivingContainerService {
ReceivingContainerRequests.BulkReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
requirePalletCapacity(request.packageCount());
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
@@ -463,6 +467,10 @@ public class ReceivingContainerService {
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getWarehousePalletId, resource.palletId())
.eq(InboundPlanCarton::getStatus, "ON_PALLET"));
requirePalletCapacity(packagesOnPallet + 1);
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
@@ -492,6 +500,8 @@ public class ReceivingContainerService {
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
requirePalletCapacity(total);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
@@ -511,7 +521,6 @@ public class ReceivingContainerService {
}
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
@@ -885,6 +894,10 @@ public class ReceivingContainerService {
|| "LOCKED".equals(pallet.getAssignmentStatus()));
}
private void requirePalletCapacity(long packageCount) {
if (packageCount > MAX_PACKAGES_PER_PALLET) throw conflict(PALLET_CAPACITY_MESSAGE);
}
private void requireMaterializedPlans(List<InboundPlan> containerPlans) {
for (InboundPlan plan : containerPlans) {
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
@@ -126,7 +126,6 @@ public class InboundPlanService {
if (order.version() != request.expectedOrderVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试");
}
if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划");
if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试");
}
@@ -280,7 +279,6 @@ public class InboundPlanService {
boolean planned = trimToNull(row.planNo()) != null;
if (!planned) {
if (trimToNull(row.inboundCode()) == null) blockers.add("MISSING_INBOUND_CODE");
if (row.detained()) blockers.add("ORDER_DETAINED");
if (!("PENDING_ASSIGNMENT".equals(row.currentStatus())
|| "DESTINATION_ASSIGNED".equals(row.currentStatus()))) blockers.add("ORDER_STATUS_NOT_READY");
if (containerNo == null || !Objects.equals(containerNo, trimToNull(row.containerNo()))) {
@@ -60,6 +60,12 @@ public interface InventoryCommandMapper {
@Param("sourceIdentity") String sourceIdentity,
@Param("skuCode") String skuCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.available_packages>0 ORDER BY p.id FOR UPDATE")
List<InventoryPosition> lockInboundAvailablePositions(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
@@ -142,6 +142,17 @@ public class InventoryCommandService {
});
}
@Transactional
public void holdInboundOrderInventory(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
for (InventoryPosition position : commands.lockInboundAvailablePositions(customerId, inboundOrderId)) {
long packages = value(position.getAvailablePackages());
if (packages == 0) continue;
hold(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages,
businessOrderNo, null, null, "ORDER_HOLD:" + inboundOrderId + ":" + position.getId(), remark));
}
}
@Transactional
public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
@@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/local-looseboard-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD + "')")
public class MerchantLocalLooseboardController {
@Resource private LocalLooseboardService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE + "')")
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody LocalLooseboardRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PutMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT + "')")
public ApiResponse<LocalLooseboardResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
@@ -22,11 +22,11 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')")
public ApiResponse<OverseasProcurementResponses.Detail> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
@@ -34,12 +34,14 @@ public class MerchantOverseasProcurementController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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;
@@ -24,6 +26,7 @@ import java.util.List;
public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service;
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@@ -31,6 +34,7 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@GetMapping("/historical-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.HistoricalOrder>> historicalOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@@ -38,20 +42,25 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')")
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
@PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')")
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
}
@PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')")
public ApiResponse<RelabelResponses.Detail> uploadFile(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId,
@RequestPart("file") MultipartFile file) {
@@ -60,18 +69,21 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind));
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadShippingLabel(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@PostMapping("/{relabelNo}/confirm-completion")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CONFIRM + "')")
public ApiResponse<RelabelResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request));
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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;
@@ -17,17 +19,22 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/return-orders")
public class MerchantReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@GetMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
@GetMapping("/{returnNo}/relabel-candidates") public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@GetMapping("/{returnNo}/relabel-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')")
public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) {
return ApiResponse.success(service.relabelCandidates(customer(user), returnNo));
}
@PostMapping("/{returnNo}/relabel") public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@PostMapping("/{returnNo}/relabel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')")
public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@Valid @RequestBody ReturnOrderRequests.RelabelCreate request) {
return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request));
@@ -45,9 +45,29 @@ public final class OverseasPermissions {
public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list";
public static final String MERCHANT_RETURN_INVENTORY_LIST =
"merchant:overseas-warehouse:return-inventory:list";
public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement";
public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking";
public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping";
public static final String MERCHANT_LAST_MILE_ORDER_LIST =
"merchant:overseas-warehouse:last-mile-order:list";
public static final String MERCHANT_RELABEL_LIST = "merchant:overseas-warehouse:relabel:list";
public static final String MERCHANT_RELABEL_CREATE = "merchant:overseas-warehouse:relabel:create";
public static final String MERCHANT_RELABEL_FILES = "merchant:overseas-warehouse:relabel:files";
public static final String MERCHANT_RELABEL_CONFIRM = "merchant:overseas-warehouse:relabel:confirm";
public static final String MERCHANT_TRANSFER_LIST = "merchant:overseas-warehouse:transfer:list";
public static final String MERCHANT_TRANSFER_CREATE = "merchant:overseas-warehouse:transfer:create";
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
public static final String MERCHANT_RETURN_ORDER_RELABEL = "merchant:overseas-warehouse:return-order:relabel";
public static final String MERCHANT_PROCUREMENT_LIST = "merchant:cross-border:procurement:list";
public static final String MERCHANT_PROCUREMENT_CREATE = "merchant:cross-border:procurement:create";
public static final String MERCHANT_TRUCKING_LIST = "merchant:cross-border:domestic-trucking:list";
public static final String MERCHANT_TRUCKING_CREATE = "merchant:cross-border:domestic-trucking:create";
public static final String MERCHANT_TRUCKING_EDIT = "merchant:cross-border:domestic-trucking:edit";
public static final String MERCHANT_LOOSEBOARD_LIST = "merchant:cross-border:pallet-shipping:list";
public static final String MERCHANT_LOOSEBOARD_CREATE = "merchant:cross-border:pallet-shipping:create";
public static final String MERCHANT_LOOSEBOARD_EDIT = "merchant:cross-border:pallet-shipping:edit";
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -51,6 +52,7 @@ public class LastlegOrderCommandService {
@Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Resource private LastlegOrderOperationLogMapper operationLogs;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private InventoryCommandService inventory;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -69,6 +71,9 @@ public class LastlegOrderCommandService {
if (Boolean.TRUE.equals(order.getDetained()) == held) {
throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态");
}
if (held && !"INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw conflict("货物完成入库后才能暂扣");
}
if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount())
|| text(order.getOutboundOrderNo()) != null)) {
throw conflict("尾程订单已进入出库流程,不能新增暂扣");
@@ -79,6 +84,13 @@ public class LastlegOrderCommandService {
if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
if (held) {
inventory.holdInboundOrderInventory(order.getCustomerId(), operatorId, orderId,
order.getOrderNo(), reason);
} else {
inventory.releaseInboundOrderHold(order.getCustomerId(), operatorId, orderId,
order.getOrderNo(), reason);
}
int nextVersion = version + 1;
LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse(
orderId, held, chargeable, held ? reason : null, nextVersion);
@@ -49,7 +49,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -80,7 +79,6 @@ public class LastlegOrderService {
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@@ -388,47 +386,6 @@ public class LastlegOrderService {
return fee;
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorName) {
setDetained(customerId, orderId, request, null, operatorName);
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorId, String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
boolean detained = request.detained();
if (Boolean.TRUE.equals(order.getDetained()) == detained) {
throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held");
}
if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held");
}
LocalDateTime now = LocalDateTime.now();
order.setDetained(detained);
order.setUpdatedAt(now);
orderMapper.updateById(order);
if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim());
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode(detained ? "暂扣" : "解除暂扣");
operation.setOperatorName(operatorName);
operation.setResult(request.remark().trim());
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
if (StringUtils.hasText(order.getSourceInboundId())) {
detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), operation.getId());
}
}
@Transactional
public void setPalletMergeRequired(String customerId, long orderId,
LastlegOrderRequests.SetPalletMergeRequired request,
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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;
@@ -27,6 +29,7 @@ public class MerchantWarehouseTransferController {
@Resource private WarehouseTransferService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
@@ -57,12 +60,14 @@ public class MerchantWarehouseTransferController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{transferNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.detail(customer(user), transferNo));
@@ -92,6 +97,7 @@ public class MerchantWarehouseTransferController {
}
@PostMapping("/{transferNo}/confirm-measurement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CONFIRM + "')")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) {
@@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')")
public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
@@ -126,21 +126,21 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post:
summary: FBA 入仓码按箱数收货
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,单托最多 66 箱;服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划但未超出单托上限的部分只记差异,不生成可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、单托超过66箱、版本变化或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/carton:
post:
summary: 海外仓入仓码逐箱收货
description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。
description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,单托最多 66 箱,拒绝跨柜、重复箱和不匹配托盘。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
post:
summary: 一件代发按完整来源托盘收货
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;单托最多 66 箱,所有客户事件分片清单一致且齐全后才允许收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
@@ -232,7 +232,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}/hold:
put:
summary: 设置或解除尾程订单暂扣
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。仅完成入库且尚未进入出库流程的订单允许新增暂扣;解除暂扣不受该状态限制。暂扣只作用于当前票并仅阻断排车和出库,不影响入库计划或同柜其他订单。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -245,7 +245,7 @@ paths:
schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' }
responses:
'200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } }
'409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 }
'409': { description: 版本冲突、幂等键冲突、重复状态、尚未完成入库或订单已进入出库流程 }
/api/platform/v1/last-leg/orders/{id}/manual-nodes:
get:
summary: 查询尾程订单人工展示节点
@@ -496,7 +496,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/manual:
post:
summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、真实入仓码、客户归属及有效公共接柜仓。暂扣只限制排车和出库,不阻断入库计划。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -508,7 +508,7 @@ paths:
responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 }
'409': { description: 订单版本、状态、柜级预计到仓时间、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成剩余入库计划
@@ -959,16 +959,85 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } }
/api/merchant/v1/last-leg/orders:
get:
summary: 客户分页查询自己的海外尾程订单
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}:
get:
summary: 客户查询自己的海外尾程订单详情
description: 权限 merchant:overseas-warehouse:last-mile-order:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/warehouse-transfers:
get:
summary: 客户分页查询自己的仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post:
summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
get:
summary: 客户查询自己的仓储转运单详情
description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:
post:
summary: 客户确认仓储转运实测数据
description: 权限 merchant:overseas-warehouse:transfer:confirm;仅当前客户满足状态门禁的转运单可确认。
security: [{ bearerAuth: [] }]
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 确认后的仓储转运单 }, '403': { description: 缺少实测确认权限 }, '409': { description: 状态或版本冲突 } }
/api/merchant/v1/last-leg/cargo-discards:
get:
summary: 客户分页查询自己的货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:list;列表权限包含详情和库存候选只读。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户货物废弃申请分页 }, '403': { description: 缺少货物废弃查看权限 } }
post:
summary: 客户创建货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的货物废弃申请 }, '403': { description: 缺少货物废弃创建权限 }, '409': { description: 库存或状态冲突 } }
/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:
get:
summary: 查询当前客户可废弃库存候选
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 可废弃库存候选 }, '403': { description: 缺少货物废弃查看权限 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}:
get:
summary: 客户查询自己的货物废弃申请详情
description: 权限 merchant:overseas-warehouse:discard:list;不泄露其他客户申请。
security: [{ bearerAuth: [] }]
parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户货物废弃申请详情 }, '403': { description: 缺少货物废弃查看权限 }, '404': { description: 当前客户下申请不存在 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel:
post:
summary: 客户取消自己的货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:cancel;仅满足状态门禁的申请可取消。
security: [{ bearerAuth: [] }]
parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 取消后的货物废弃申请 }, '403': { description: 缺少货物废弃取消权限 }, '409': { description: 状态冲突 } }
/api/merchant/v1/last-leg/overseas-procurements:
post:
summary: 客户创建海外采购订单
description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } }
get:
summary: 客户分页查询自己的海外采购订单
description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -984,14 +1053,14 @@ paths:
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
get:
summary: 客户查询自己的海外采购订单详情及流水
description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。
description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/us-truck-orders:
get:
summary: 客户分页查询自己的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:domestic-trucking:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1005,7 +1074,7 @@ paths:
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post:
summary: 客户创建美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -1013,19 +1082,19 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的美国本土卡订单详情
description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。
description: 权限 merchant:cross-border:domestic-trucking:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
patch:
summary: 客户编辑待审核的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。
description: 权限 merchant:cross-border:domestic-trucking:edit;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
/api/merchant/v1/last-leg/local-looseboard-orders:
get:
summary: 客户分页查询自己的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1039,7 +1108,7 @@ paths:
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post:
summary: 客户创建本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -1047,12 +1116,12 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put:
summary: 客户编辑待接单的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
@@ -1310,7 +1379,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:
get:
summary: 查询客户可换标库存候选
description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
description: 权限 merchant:overseas-warehouse:relabel:list;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
security: [{ bearerAuth: [] }]
parameters:
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
@@ -1321,7 +1390,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications:
get:
summary: 分页查询客户换标申请
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1337,7 +1406,7 @@ paths:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
@@ -1357,7 +1426,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:
get:
summary: 查询客户换标申请详情
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。
description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
responses:
@@ -1366,7 +1435,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:
post:
summary: 上传换标快递面单
description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。
description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
@@ -1374,10 +1443,21 @@ paths:
'200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'400': { description: 文件类型或大小不合法 }
'409': { description: 当前状态不允许上传 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/files:
post:
summary: 上传客户换标总文件或明细文件
description: 权限 merchant:overseas-warehouse:relabel:files;文件只能绑定到当前客户的换标申请。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
- { name: scope, in: query, required: true, schema: { type: string, enum: [CARTON_TOTAL, SKU_TOTAL, ITEM] } }
- { name: itemId, in: query, required: false, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: 更新后的换标详情 }, '403': { description: 缺少换标文件维护权限 }, '409': { description: 状态或归属冲突 } }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download:
get:
summary: 下载换标快递面单
description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。
description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载当前申请已绑定的面单文件。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
@@ -1388,7 +1468,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download:
get:
summary: 下载换标完成照片
description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。
description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载该申请 PDA 完工证据中的照片。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
@@ -1399,7 +1479,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:
post:
summary: 客户确认换标完成
description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } }
@@ -1409,7 +1489,7 @@ paths:
/api/merchant/v1/last-leg/return-orders:
get:
summary: 客户分页查询退货签收订单
description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。
description: 权限 merchant:overseas-warehouse:return-order:list;客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1421,17 +1501,31 @@ paths:
responses: { '200': { description: 当前客户退货订单分页 } }
post:
summary: 客户创建退货预报订单
description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。
description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } }
responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}:
get:
summary: 客户查询退货订单与签收批次
description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/relabel-candidates:
get:
summary: 查询退货单可转换标候选
description: 权限 merchant:overseas-warehouse:return-order:relabel;只返回当前客户退货单的可转换标明细。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 可转换标候选 }, '403': { description: 缺少退货转换标权限 }, '404': { description: 当前客户下退货单不存在 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/relabel:
post:
summary: 将退货库存转换为换标申请
description: 权限 merchant:overseas-warehouse:return-order:relabel;只能处理当前客户且满足状态门禁的退货库存。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 创建后的换标申请 }, '403': { description: 缺少退货转换标权限 }, '409': { description: 状态、库存或幂等冲突 } }
/api/pda/v1/last-leg/return-receipts/start:
post:
summary: PDA 以退件单号开始签收
@@ -1950,7 +2044,7 @@ components:
properties:
inboundCode: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 }
packageCount: { type: integer, minimum: 1, maximum: 66 }
expectedPalletVersion: { type: integer, minimum: 0, nullable: true }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingCarton:
@@ -153,6 +153,8 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3"));
assertTrue(yaml.contains("required: [role, fileId, downloadPath]"));
assertTrue(yaml.contains("packageCount: { type: integer, minimum: 1, maximum: 66 }"));
assertTrue(receivingPaths.contains("单托最多 66 箱"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
@@ -49,6 +49,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -422,6 +423,55 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(workflow);
}
@Test
void fbaPalletAccepts66PackagesAndRejects67BeforeReceivingStarts() {
stubCurrent("FBA", "CONT-A");
InboundPlanLine fbaLine = line("FBA");
fbaLine.setRequiredPackageCount(66);
when(lines.selectList(any())).thenReturn(List.of(fbaLine));
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 66)));
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of());
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var accepted = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 66, "capacity-66"));
assertThat(accepted.acceptedPackages()).isEqualTo(66);
org.mockito.Mockito.reset(sessions, workflow, warehouse);
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 67, "capacity-67")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verifyNoInteractions(sessions, workflow, warehouse);
}
@Test
void overseasCartonIsRejectedWhenPhysicalPalletAlreadyContains66Packages() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "IN-1#67"));
InboundPlanPallet reserved = planPallet();
reserved.setPalletNo("PAL-1"); reserved.setWarehousePalletId(30L); reserved.setWarehouseLocationId(40L);
reserved.setWarehouseResourceLockId(50L); reserved.setAssignmentStatus("RESERVED");
when(pallets.selectList(any())).thenReturn(List.of(reserved));
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
.thenReturn(resource(100L, null));
when(cartons.selectCount(any())).thenReturn(66L);
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("IN-1#67", "PAL-1", "carton-capacity")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verify(workflow, never()).validateCartonResolved(any(), any(), any(), any(), any());
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
@@ -950,6 +1000,29 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(warehouse);
}
@Test
void dropshipWholePalletOver66PackagesIsRejectedBeforePicking() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("DP-260909-0067"); pallet.setSourcePalletNo("DP-260909-0067");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(67L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(67);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending));
stubCompleteManifest("DP-260909-0067", 67);
assertThatThrownBy(() -> service.receiveWholePallet("7", "receiver",
new ReceivingContainerRequests.WholePalletReceive("DP-260909-0067", "whole-capacity")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verify(workflow, never()).pickWholePallet(any(), any(), anyLong(), any());
}
@Test
void domesticLoadingPhotoDownloadIsScopedToTheCurrentPhysicalContainer() {
ReceivingContainerSession current = session("CONT-A", "SIGNED");
@@ -85,6 +85,22 @@ class ContainerInboundPlanPreviewTest {
assertFalse(result.ready());
}
@Test
void heldHistoricalOrderRemainsReadyBecauseHoldOnlyBlocksOutbound() {
when(orderMapper.containerPreviewOrders("BOOKING-HELD")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-HELD", "FO-1",
"测试客户", "FIRST_LEG", "FBA", "IN-1", "PENDING_ASSIGNMENT", null,
"MSCU6639870", 1, 10, new BigDecimal("1.0000"), "193000000000200003", "US-LAX-01",
"洛杉矶仓", "ACY2", null, null, "Amazon ACY2", 0, true, 1,
null, null, null, null, 1)));
var result = service.previewContainer("BOOKING-HELD");
assertEquals(true, result.ready());
assertEquals("READY", result.groups().get(0).orders().get(0).readiness());
assertEquals(List.of(), result.groups().get(0).orders().get(0).blockers());
}
@Test
void dropshipPreviewKeepsUnderlyingFbaFactSeparateFromItsDisplayCategory() {
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP")).thenReturn(List.of(
@@ -352,21 +352,23 @@ class InboundPlanServiceTest {
}
@Test
void refusesManualPlanForHeldOrderBeforeWriting() {
void createsManualPlanForHeldHistoricalOrderBecauseHoldOnlyBlocksOutbound() {
SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, "WH-A", "仓A", 1, 0, true, 5);
"WH-A", "WH-A", "仓A", 1, 0, true, 5);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any()))
.thenReturn(1);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
var result = service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5,
1, "STANDARD", false, false, false, null),
"manual-held", "900"));
"manual-held", "900");
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
assertEquals(2L, result.sourceOrderId());
verify(planMapper).insert(any(InboundPlan.class));
verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any());
}
@Test
@@ -280,6 +280,23 @@ class InventoryCommandServiceTest {
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test
void holdsAllAvailablePositionsForOneInboundOrder() {
InventoryBalance balance = balance(200L, 5);
InventoryPosition position = position(201L, 200L, 5);
when(commands.lockInboundAvailablePositions("100", 101L)).thenReturn(List.of(position));
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
service.holdInboundOrderInventory("100", "7", 101L, "ORD-1", "质检暂扣");
assertEquals(0L, position.getAvailablePackages());
assertEquals(5L, position.getHoldPackages());
assertEquals(0L, balance.getAvailablePackages());
assertEquals(5L, balance.getHoldPackages());
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
@@ -1,14 +1,19 @@
package com.baiyue.lastleg.merchant;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.controller.MerchantLastlegOrderController;
import com.baiyue.lastleg.inboundplan.controller.MerchantInboundPlanController;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.discard.controller.MerchantCargoDiscardController;
import com.baiyue.lastleg.looseboard.controller.MerchantLocalLooseboardController;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.procurement.controller.MerchantOverseasProcurementController;
import com.baiyue.lastleg.relabel.controller.MerchantRelabelApplicationController;
import com.baiyue.lastleg.returnreceipt.controller.MerchantReturnOrderController;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.transfer.controller.MerchantWarehouseTransferController;
import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -43,15 +49,57 @@ class MerchantOverseasCustomerApiContractTest {
}
@Test
void merchantControllersUseFrozenPathsAndExistingClientPermissionCodes() {
assertController(MerchantOverseasProcurementController.class,
"/api/merchant/v1/last-leg/overseas-procurements", OverseasPermissions.MERCHANT_PROCUREMENT);
assertController(MerchantUsTruckOrderController.class,
"/api/merchant/v1/last-leg/us-truck-orders", OverseasPermissions.MERCHANT_TRUCKING);
assertController(MerchantLocalLooseboardController.class,
"/api/merchant/v1/last-leg/local-looseboard-orders", OverseasPermissions.MERCHANT_LOOSEBOARD);
void merchantControllersUseFrozenPathsAndOperationPermissions() {
assertPath(MerchantOverseasProcurementController.class, "/api/merchant/v1/last-leg/overseas-procurements");
assertPermissions(MerchantOverseasProcurementController.class, Map.of(
"create", OverseasPermissions.MERCHANT_PROCUREMENT_CREATE,
"page", OverseasPermissions.MERCHANT_PROCUREMENT_LIST,
"detail", OverseasPermissions.MERCHANT_PROCUREMENT_LIST));
assertPath(MerchantUsTruckOrderController.class, "/api/merchant/v1/last-leg/us-truck-orders");
assertPermissions(MerchantUsTruckOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_TRUCKING_CREATE,
"page", OverseasPermissions.MERCHANT_TRUCKING_LIST,
"detail", OverseasPermissions.MERCHANT_TRUCKING_LIST,
"update", OverseasPermissions.MERCHANT_TRUCKING_EDIT));
assertPath(MerchantLocalLooseboardController.class, "/api/merchant/v1/last-leg/local-looseboard-orders");
assertPermissions(MerchantLocalLooseboardController.class, Map.of(
"create", OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE,
"page", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST,
"detail", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST,
"update", OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT));
assertController(MerchantInboundPlanController.class,
"/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST);
assertController(MerchantLastlegOrderController.class,
"/api/merchant/v1/last-leg/orders", OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST);
assertPermissions(MerchantWarehouseTransferController.class, Map.of(
"create", OverseasPermissions.MERCHANT_TRANSFER_CREATE,
"page", OverseasPermissions.MERCHANT_TRANSFER_LIST,
"detail", OverseasPermissions.MERCHANT_TRANSFER_LIST,
"confirmMeasurement", OverseasPermissions.MERCHANT_TRANSFER_CONFIRM));
assertPermissions(MerchantCargoDiscardController.class, Map.of(
"candidates", OverseasPermissions.MERCHANT_DISCARD_LIST,
"create", OverseasPermissions.MERCHANT_DISCARD_CREATE,
"page", OverseasPermissions.MERCHANT_DISCARD_LIST,
"detail", OverseasPermissions.MERCHANT_DISCARD_LIST,
"cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL));
assertPermissions(MerchantReturnOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"relabelCandidates", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL,
"createRelabel", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL));
assertPermissions(MerchantRelabelApplicationController.class, Map.of(
"candidates", OverseasPermissions.MERCHANT_RELABEL_LIST,
"historicalOrders", OverseasPermissions.MERCHANT_RELABEL_LIST,
"create", OverseasPermissions.MERCHANT_RELABEL_CREATE,
"page", OverseasPermissions.MERCHANT_RELABEL_LIST,
"detail", OverseasPermissions.MERCHANT_RELABEL_LIST,
"uploadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_FILES,
"uploadFile", OverseasPermissions.MERCHANT_RELABEL_FILES,
"downloadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_LIST,
"downloadCompletionPhoto", OverseasPermissions.MERCHANT_RELABEL_LIST,
"confirmCompletion", OverseasPermissions.MERCHANT_RELABEL_CONFIRM));
}
@Test
@@ -90,11 +138,27 @@ class MerchantOverseasCustomerApiContractTest {
}
private static void assertController(Class<?> type, String path, String permission) {
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
assertPath(type, path);
assertThat(type.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static void assertPath(Class<?> type, String path) {
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
}
private static void assertPermissions(Class<?> type, Map<String, String> permissions) {
for (var entry : permissions.entrySet()) {
var method = Arrays.stream(type.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(entry.getKey()))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class))
.as(type.getSimpleName() + "." + entry.getKey()).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + entry.getValue() + "')");
}
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of());
}
@@ -16,6 +16,15 @@ class MerchantOverseasOpenApiContractTest {
}
for (String path : new String[] {
"/api/merchant/v1/last-leg/orders:",
"/api/merchant/v1/last-leg/orders/{id}:",
"/api/merchant/v1/last-leg/warehouse-transfers:",
"/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:",
"/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:",
"/api/merchant/v1/last-leg/cargo-discards:",
"/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:",
"/api/merchant/v1/last-leg/cargo-discards/{discardNo}:",
"/api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel:",
"/api/merchant/v1/last-leg/overseas-procurements:",
"/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:",
"/api/merchant/v1/last-leg/us-truck-orders:",
@@ -27,9 +36,28 @@ class MerchantOverseasOpenApiContractTest {
assertThat(yaml).contains(path);
assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId");
}
assertThat(yaml).contains("merchant:cross-border:procurement")
.contains("merchant:cross-border:domestic-trucking")
.contains("merchant:cross-border:pallet-shipping")
assertThat(yaml).contains("merchant:cross-border:procurement:list")
.contains("merchant:cross-border:procurement:create")
.contains("merchant:cross-border:domestic-trucking:list")
.contains("merchant:cross-border:domestic-trucking:create")
.contains("merchant:cross-border:domestic-trucking:edit")
.contains("merchant:cross-border:pallet-shipping:list")
.contains("merchant:cross-border:pallet-shipping:create")
.contains("merchant:cross-border:pallet-shipping:edit")
.contains("merchant:overseas-warehouse:last-mile-order:list")
.contains("merchant:overseas-warehouse:transfer:list")
.contains("merchant:overseas-warehouse:transfer:create")
.contains("merchant:overseas-warehouse:transfer:confirm")
.contains("merchant:overseas-warehouse:discard:list")
.contains("merchant:overseas-warehouse:discard:create")
.contains("merchant:overseas-warehouse:discard:cancel")
.contains("merchant:overseas-warehouse:relabel:list")
.contains("merchant:overseas-warehouse:relabel:create")
.contains("merchant:overseas-warehouse:relabel:files")
.contains("merchant:overseas-warehouse:relabel:confirm")
.contains("merchant:overseas-warehouse:return-order:list")
.contains("merchant:overseas-warehouse:return-order:create")
.contains("merchant:overseas-warehouse:return-order:relabel")
.contains("merchant:warehouse:inbound:list")
.contains("#/components/schemas/ProcurementDetailResponse")
.contains("#/components/schemas/TruckDetailResponse")
@@ -96,7 +96,10 @@ class RelabelContractTest {
}
assertTrue(openApi.contains("RelabelMerchantPageResponse"));
assertTrue(openApi.contains("RelabelCustomerConfirm"));
assertTrue(openApi.contains("merchant:warehouse:relabel"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:list"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:create"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:files"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:confirm"));
String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"),
openApi.indexOf("components:"));
assertTrue(relabelPaths.contains("name: customerId, in: query, required: true"));
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,6 +49,7 @@ class LastlegOrderCommandServiceTest {
@Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Mock LastlegOrderOperationLogMapper operationLogs;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock InventoryCommandService inventory;
LastlegOrderCommandService service;
@BeforeEach
@@ -60,12 +62,14 @@ class LastlegOrderCommandServiceTest {
ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory);
ReflectionTestUtils.setField(service, "operationLogs", operationLogs);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);
order.setCurrentStatus("INBOUND_COMPLETED");
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3),
@@ -80,9 +84,42 @@ class LastlegOrderCommandServiceTest {
verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any());
verify(holdHistory).insert(any(LastlegOrderHoldHistory.class));
verify(commands).insert(any(LastlegOrderCommandRecord.class));
verify(inventory).holdInboundOrderInventory("100", "900", 11L, "LL-11", "海关查验");
verify(orders, never()).lockContainerOrders(any());
}
@Test
void releasingHoldMovesOrderInventoryBackToAvailable() {
LastlegOrder order = order(11L, 3);
order.setCurrentStatus("INBOUND_COMPLETED");
order.setDetained(true);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(false), eq(false), eq("质检通过"), eq(3),
any(LocalDateTime.class))).thenReturn(1);
service.hold("100", 11L, "release-11",
new LastlegOrderCommandDtos.HoldRequest(false, false, "质检通过", 3), "900", "管理员");
verify(inventory).releaseInboundOrderHold("100", "900", 11L, "LL-11", "质检通过");
}
@Test
void rejectsNewHoldBeforeInboundCompletion() {
LastlegOrder order = order(11L, 3);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L,
"hold-before-inbound", new LastlegOrderCommandDtos.HoldRequest(true, false, "待入库", 3),
"900", "管理员"));
assertEquals(409, error.getStatusCode().value());
assertEquals("货物完成入库后才能暂扣", error.getReason());
verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(),
nullable(String.class), anyInt(), any(LocalDateTime.class));
}
@Test
void rejectsStaleHoldWithoutAnyWrite() {
LastlegOrder order = order(11L, 4);
@@ -51,7 +51,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -71,7 +70,6 @@ class LastlegOrderServiceTest {
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
@@ -118,7 +116,6 @@ class LastlegOrderServiceTest {
Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2),
"US", "CA"));
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
@@ -586,40 +583,6 @@ class LastlegOrderServiceTest {
assertEquals("NYEXX2609060063", result.records().get(2).inboundCode());
}
@Test
void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() {
LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(true, "等待质检"), "operator");
assertTrue(order.getDetained());
verify(orders).updateById(order);
ArgumentCaptor<com.baiyue.lastleg.entity.LastlegOrderOperationLog> operation =
ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class);
verify(operations).insert(operation.capture());
assertEquals("暂扣", operation.getValue().getOperationNode());
assertEquals("等待质检", operation.getValue().getResult());
verify(detainedOutbox).publish(eq(order), eq(true), eq("等待质检"), isNull(), eq("operator"), any(), anyLong());
verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any());
}
@Test
void releasingAfterInboundMovesAllOrderHeldInventoryToAvailable() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IP-1");
order.setOrderNo("LM-1");
order.setDetained(true);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(false, "质检通过"), "operator");
verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过");
assertEquals(false, order.getDetained());
verify(detainedOutbox).publish(eq(order), eq(false), eq("质检通过"), isNull(), eq("operator"), any(), anyLong());
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
@@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
})
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 346, iam_role_permission: 521,
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 364, iam_role_permission: 539,
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
+2 -1
View File
@@ -589,7 +589,7 @@ foreach ($gatewayPath in @(
'/api/merchant/v1/last-leg/**',
'/api/platform/v1/last-leg/**',
'/api/pda/v1/last-leg/**',
'/api/merchant/v1/warehouses/**',
'/api/platform/v1/warehouses/**',
'/api/platform/v1/warehouse-admin/**'
)) {
Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由'
@@ -1136,6 +1136,7 @@ $criticalPaths | ForEach-Object { Assert-Path $_ }
foreach ($removedOverseasWarehouseMasterPath in @(
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java',
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java',
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java',
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java',
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java',
'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java'
+1 -1
View File
@@ -11,7 +11,7 @@
| 功能 | 入口 |
|---|---|
| 国内仓库只读目录 | `client/DomesticWarehouseDirectoryClient.java`、`service/DomesticWarehouseDirectory.java`;正结果缓存 30 分钟,空值和不存在不得长缓存 |
| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` |
| 平台库位与托盘 | `controller/PlatformWarehouseResourceController.java`;企业端不得直接维护物理库位与托盘 |
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
@@ -1,109 +0,0 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}")
public class MerchantWarehouseResourceController {
@Resource private WarehouseResourceService service;
@PostMapping("/locations")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody LocationRequests.SaveRequest request) {
return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request));
}
@PutMapping("/locations/{id}")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody LocationRequests.SaveRequest request) {
service.updateLocation(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/locations")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid LocationRequests.QueryRequest query) {
return ApiResponse.success(service.locations(tenant(user), warehouseId, query));
}
@GetMapping("/locations/{id}")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@PathVariable long id) {
return ApiResponse.success(service.location(tenant(user), warehouseId, id));
}
@DeleteMapping("/locations/{id}")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deleteLocation(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
@PostMapping("/pallets")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request));
}
@PutMapping("/pallets/{id}")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
service.updatePallet(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/pallets")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid PalletRequests.QueryRequest query) {
return ApiResponse.success(service.pallets(tenant(user), warehouseId, query));
}
@GetMapping("/pallets/{id}")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
return ApiResponse.success(service.pallet(tenant(user), warehouseId, id));
}
@DeleteMapping("/pallets/{id}")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deletePallet(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
private static long tenant(AuthenticatedUser user) {
if (user == null || user.tenantId() == null || user.tenantId() <= 0) {
throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失");
}
return user.tenantId();
}
}
@@ -32,18 +32,6 @@ class WarehouseResourceControllerTest {
verify(labels).print(200L, 9L, "r1");
}
@Test
void merchantTenantAlwaysComesFromAuthenticatedUser() {
MerchantWarehouseResourceController controller = merchantController();
AuthenticatedUser merchant = user(10001L, "MERCHANT");
WarehouseLocationEntity expected = new WarehouseLocationEntity();
when(service.location(10001L, 20001L, 30001L)).thenReturn(expected);
controller.location(merchant, 20001L, 30001L);
verify(service).location(10001L, 20001L, 30001L);
}
@Test
void platformApiRejectsNonPlatformIdentity() {
PlatformWarehouseResourceController controller = platformController();
@@ -79,12 +67,6 @@ class WarehouseResourceControllerTest {
"tester", appCode, "WEB", Set.of());
}
private MerchantWarehouseResourceController merchantController() {
MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
private PlatformWarehouseResourceController platformController() {
PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);