This commit is contained in:
2026-08-06 11:49:51 +08:00
parent a11e47fe3b
commit 7dd92f1c84
91 changed files with 6233 additions and 174 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
- 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。 - 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 - 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
@@ -3762,6 +3762,672 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; ) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
-- ============================================================ -- ============================================================
-- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库
-- 来源:warehouse/src/main/resources/db/warehouse-public-master-data.sql。必须位于最终 PASS 校验之前,可重复执行。
USE baiyue_warehouse;
-- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。
-- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。
-- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。
-- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。
-- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse;
CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse (
warehouse_id_seed BIGINT UNSIGNED NOT NULL,
address_id_seed BIGINT UNSIGNED NOT NULL,
warehouse_code VARCHAR(50) NOT NULL,
warehouse_name VARCHAR(120) NOT NULL,
warehouse_type VARCHAR(32) NOT NULL,
country_code CHAR(2) NOT NULL,
timezone VARCHAR(64) NOT NULL,
state_province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
address_line1 VARCHAR(255) NOT NULL,
PRIMARY KEY (warehouse_code),
UNIQUE KEY uk_tmp_fixed_warehouse_id_seed (warehouse_id_seed),
UNIQUE KEY uk_tmp_fixed_address_id_seed (address_id_seed)
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tmp_wh_fixed_platform_warehouse
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1)
VALUES
(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'),
(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'),
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'),
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance');
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
DELIMITER //
CREATE PROCEDURE assert_wh_fixed_platform_warehouse_seed()
BEGIN
IF (SELECT COUNT(*) FROM tmp_wh_fixed_platform_warehouse) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓源数量不是4';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fixed_platform_warehouse
WHERE warehouse_code <> UPPER(TRIM(warehouse_code))
OR warehouse_type NOT IN ('DOMESTIC', 'OVERSEAS')
OR country_code NOT IN ('CN', 'US')
OR TRIM(timezone) = ''
OR TRIM(city) = ''
OR TRIM(state_province) = ''
OR TRIM(address_line1) = ''
OR TRIM(postal_code) = ''
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓主数据存在未清洗或必填缺失记录';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.id = source.warehouse_id_seed
AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓仓库种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse_address existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.id = source.address_id_seed
LEFT JOIN wh_warehouse parent
ON parent.id = existing.warehouse_id
AND parent.tenant_id = existing.tenant_id
WHERE existing.tenant_id <> 0
OR existing.deleted <> 0
OR parent.warehouse_code IS NULL
OR parent.warehouse_code <> source.warehouse_code
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.tenant_id = 0
AND existing.warehouse_code = source.warehouse_code
AND existing.deleted = 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓存在同编码已删除仓库,需人工确认后恢复或清理';
END IF;
END//
DELIMITER ;
CALL assert_wh_fixed_platform_warehouse_seed();
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
INSERT INTO wh_warehouse (
id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone,
contact_name, contact_phone, contact_email, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.warehouse_id_seed,
0,
source.warehouse_code,
source.warehouse_name,
source.warehouse_type,
source.country_code,
source.timezone,
NULL,
NULL,
NULL,
'ACTIVE',
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fixed_platform_warehouse source
ON DUPLICATE KEY UPDATE
warehouse_name = VALUES(warehouse_name),
warehouse_type = VALUES(warehouse_type),
country_code = VALUES(country_code),
timezone = VALUES(timezone),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO wh_warehouse_address (
id, tenant_id, warehouse_id, address_type, country_code, state_province, city,
district, postal_code, address_line1, address_line2, latitude, longitude,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.address_id_seed,
0,
warehouse.id,
'OPERATING',
source.country_code,
source.state_province,
source.city,
NULL,
source.postal_code,
source.address_line1,
NULL,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fixed_platform_warehouse source
JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
ON DUPLICATE KEY UPDATE
tenant_id = VALUES(tenant_id);
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
warehouse_id_seed BIGINT UNSIGNED NOT NULL,
address_id_seed BIGINT UNSIGNED NOT NULL,
warehouse_code VARCHAR(50) NOT NULL,
warehouse_name VARCHAR(120) NOT NULL,
state_province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
timezone VARCHAR(64) NOT NULL,
address_line1 VARCHAR(255) NOT NULL,
source_row INT NOT NULL,
PRIMARY KEY (warehouse_code),
UNIQUE KEY uk_tmp_fba_warehouse_id_seed (warehouse_id_seed),
UNIQUE KEY uk_tmp_fba_address_id_seed (address_id_seed)
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tmp_wh_fba_public_master_data
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row)
VALUES
(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238),
(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237),
(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134),
(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231),
(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125),
(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99),
(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84),
(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141),
(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27),
(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148),
(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174),
(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129),
(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31),
(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147),
(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202),
(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233),
(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232),
(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289),
(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208),
(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207),
(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206),
(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11),
(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221),
(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131),
(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140),
(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220),
(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110),
(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243),
(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193),
(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192),
(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242),
(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241),
(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102),
(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225),
(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223),
(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222),
(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75),
(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191),
(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114),
(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240),
(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181),
(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262),
(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261),
(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260),
(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259),
(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135),
(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123),
(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120),
(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189),
(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182),
(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143),
(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219),
(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217),
(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256),
(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255),
(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254),
(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253),
(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117),
(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176),
(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212),
(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211),
(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89),
(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121),
(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171),
(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151),
(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67),
(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78),
(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68),
(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164),
(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224),
(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188),
(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187),
(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80),
(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87),
(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44),
(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216),
(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215),
(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214),
(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154),
(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142),
(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126),
(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155),
(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3),
(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136),
(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2),
(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152),
(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119),
(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10),
(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6),
(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5),
(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88),
(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276),
(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275),
(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274),
(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173),
(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9),
(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22),
(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69),
(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74),
(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70),
(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40),
(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39),
(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33),
(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21),
(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8),
(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7),
(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4),
(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72),
(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150),
(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178),
(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161),
(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100),
(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247),
(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246),
(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132),
(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162),
(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183),
(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86),
(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268),
(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267),
(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107),
(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291),
(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292),
(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293),
(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294),
(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106),
(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32),
(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157),
(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271),
(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12),
(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115),
(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230),
(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128),
(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200),
(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199),
(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198),
(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197),
(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196),
(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163),
(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118),
(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201),
(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77),
(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270),
(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269),
(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203),
(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94),
(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190),
(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17),
(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299),
(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300),
(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133),
(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19),
(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303),
(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302),
(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301),
(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298),
(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103),
(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195),
(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113),
(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81),
(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127),
(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288),
(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287),
(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236),
(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235),
(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20),
(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234),
(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286),
(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285),
(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307),
(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306),
(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305),
(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304),
(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229),
(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158),
(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46),
(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38),
(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45),
(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49),
(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93),
(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28),
(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71),
(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35),
(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14),
(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146),
(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95),
(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145),
(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16),
(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210),
(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209),
(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144),
(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248),
(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245),
(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244),
(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73),
(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218),
(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149),
(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15),
(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98),
(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185),
(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179),
(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97),
(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60),
(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186),
(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104),
(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266),
(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265),
(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264),
(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263),
(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258),
(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273),
(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257),
(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205),
(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204),
(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295),
(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153),
(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13),
(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184),
(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105),
(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296),
(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109),
(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194),
(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122),
(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76),
(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41),
(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43),
(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250),
(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130),
(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249),
(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156),
(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65),
(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18),
(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284),
(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283),
(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124),
(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272),
(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172),
(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228),
(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227),
(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82),
(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36),
(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37),
(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25),
(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34),
(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23),
(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63),
(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26),
(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24),
(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213),
(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226),
(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297),
(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280);
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
DELIMITER //
CREATE PROCEDURE assert_wh_fba_public_master_data_seed()
BEGIN
IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fba_public_master_data
WHERE warehouse_code <> UPPER(TRIM(warehouse_code))
OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$'
OR TRIM(city) = ''
OR TRIM(state_province) = ''
OR TRIM(address_line1) = ''
OR TRIM(postal_code) = ''
OR TRIM(timezone) = ''
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fba_public_master_data source
ON existing.id = source.warehouse_id_seed
AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据仓库种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse_address existing
JOIN tmp_wh_fba_public_master_data source
ON existing.id = source.address_id_seed
LEFT JOIN wh_warehouse parent
ON parent.id = existing.warehouse_id
AND parent.tenant_id = existing.tenant_id
WHERE existing.tenant_id <> 0
OR existing.deleted <> 0
OR parent.warehouse_code IS NULL
OR parent.warehouse_code <> source.warehouse_code
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据地址种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fba_public_master_data source
ON existing.tenant_id = 0
AND existing.warehouse_code = source.warehouse_code
AND existing.deleted = 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在同编码已删除仓库,需人工确认后恢复或清理';
END IF;
END//
DELIMITER ;
CALL assert_wh_fba_public_master_data_seed();
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
INSERT INTO wh_warehouse (
id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone,
contact_name, contact_phone, contact_email, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.warehouse_id_seed,
0,
source.warehouse_code,
source.warehouse_name,
'FBA',
'US',
source.timezone,
NULL,
NULL,
NULL,
'ACTIVE',
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fba_public_master_data source
ON DUPLICATE KEY UPDATE
warehouse_type = VALUES(warehouse_type),
country_code = VALUES(country_code),
timezone = VALUES(timezone),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO wh_warehouse_address (
id, tenant_id, warehouse_id, address_type, country_code, state_province, city,
district, postal_code, address_line1, address_line2, latitude, longitude,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.address_id_seed,
0,
warehouse.id,
'OPERATING',
'US',
source.state_province,
source.city,
NULL,
source.postal_code,
source.address_line1,
NULL,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fba_public_master_data source
JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
ON DUPLICATE KEY UPDATE
tenant_id = VALUES(tenant_id),
country_code = VALUES(country_code),
state_province = VALUES(state_province),
city = VALUES(city),
postal_code = VALUES(postal_code),
address_line1 = VALUES(address_line1),
updated_at = CURRENT_TIMESTAMP(3);
DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data;
DELIMITER //
CREATE PROCEDURE verify_wh_fba_public_master_data()
BEGIN
IF (SELECT COUNT(*)
FROM wh_warehouse warehouse
JOIN tmp_wh_fba_public_master_data source
ON source.warehouse_code = warehouse.warehouse_code
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND warehouse.country_code = 'US'
AND warehouse.deleted = 0) <> 252 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fba_public_master_data source
LEFT JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
LEFT JOIN wh_warehouse_address address
ON address.tenant_id = 0
AND address.warehouse_id = warehouse.id
AND address.address_type = 'OPERATING'
AND address.deleted = 0
WHERE warehouse.id IS NULL
OR address.id IS NULL
OR address.country_code <> 'US'
OR address.state_province <> source.state_province
OR address.city <> source.city
OR address.postal_code <> source.postal_code
OR address.address_line1 <> source.address_line1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址父子解析或地址字段校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM wh_service_capability capability
JOIN wh_warehouse warehouse
ON warehouse.id = capability.warehouse_id
AND warehouse.tenant_id = capability.tenant_id
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND warehouse.warehouse_code IN (SELECT warehouse_code FROM tmp_wh_fba_public_master_data)
AND capability.deleted = 0
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址不得创建平台作业能力';
END IF;
END//
DELIMITER ;
CALL verify_wh_fba_public_master_data();
DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data;
DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse;
DELIMITER //
CREATE PROCEDURE verify_wh_fixed_platform_warehouse()
BEGIN
IF (SELECT COUNT(*)
FROM wh_warehouse warehouse
JOIN tmp_wh_fixed_platform_warehouse source
ON source.warehouse_code = warehouse.warehouse_code
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = source.warehouse_type
AND warehouse.country_code = source.country_code
AND warehouse.deleted = 0) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓写入数量校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fixed_platform_warehouse source
LEFT JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
LEFT JOIN wh_warehouse_address address
ON address.tenant_id = 0
AND address.warehouse_id = warehouse.id
AND address.address_type = 'OPERATING'
AND address.deleted = 0
WHERE warehouse.id IS NULL
OR address.id IS NULL
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败';
END IF;
END//
DELIMITER ;
CALL verify_wh_fixed_platform_warehouse();
DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse;
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse;
-- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS -- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS
-- ============================================================ -- ============================================================
DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
@@ -0,0 +1,51 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 使用的装柜证据文件内部接口。 */
@RestController
@RequestMapping("/internal/file/v1/loading-evidence")
public class InternalLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,54 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.Resource;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 平台及作业端装柜证据上传和读取。 */
@RestController
@RequestMapping("/api/platform/v1/files/loading-evidence")
public class PlatformLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null
|| user.tenantId() != 0L || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
}
@@ -0,0 +1,74 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
/** 装柜四步照片和铅封证据的受控文件入口。 */
@Service
public class LoadingEvidenceService {
@Resource private MeasurementImageStorage storage;
@Resource private FileObjectMapper files;
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType,
MultipartFile file) {
validateType(evidenceType);
var stored = storage.storeLoading(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("LOADING_" + evidenceType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("loading-evidence");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.size());
object.setSha256(stored.sha256());
object.setStatus("ACTIVE");
object.setCreatedBy(operatorId); object.setUpdatedBy(operatorId);
object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false); object.setVersion(0);
files.insert(object);
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType());
}
public FileMetadata validate(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType());
}
public Download download(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.likeRight(FileObjectEntity::getBusinessType, "LOADING_")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
return object;
}
private static void validateType(String type) {
if (type == null || !(type.matches("PHOTO_STEP_[1-4]") || "SEAL".equals(type))) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {}
public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -30,13 +30,21 @@ public class MeasurementImageStorage {
void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); } void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); }
public Stored store(long tenantId, long inboundId, MultipartFile file) { public Stored store(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "measurements");
}
public Stored storeLoading(long tenantId, long inboundId, MultipartFile file) {
return store(tenantId, inboundId, file, "loading-evidence");
}
private Stored store(long tenantId, long inboundId, MultipartFile file, String directory) {
if (file == null || file.isEmpty()) throw bad("称重图片不能为空"); if (file == null || file.isEmpty()) throw bad("称重图片不能为空");
if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB"); if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP"); if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP");
verifyImage(file); verifyImage(file);
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg"); String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension; String objectKey = directory + "/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize(); Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try { try {
@@ -0,0 +1,51 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
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.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoadingEvidenceServiceTest {
@Mock MeasurementImageStorage storage;
@Mock FileObjectMapper files;
@InjectMocks LoadingEvidenceService service;
@Test
void uploadsEvidenceWithDedicatedBusinessType() {
var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1});
when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
"loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart);
assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2");
verify(files).insert(any(FileObjectEntity.class));
}
@Test
void rejectsUnknownEvidenceType() {
var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1});
assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class);
}
@Test
void validatesTenantBoundFile() {
FileObjectEntity object = new FileObjectEntity();
object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg");
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L);
}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
public interface LoadingEvidenceFileClient {
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<FileMetadata> validate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
}
@@ -10,7 +10,6 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService; import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -36,12 +35,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/inbounds") @RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController { public class PlatformInboundMeasurementReviewController {
@Resource private MeasurementReviewService service; @Resource private MeasurementReviewService service;
@Resource private AutomaticInboundCompletionService automaticCompletion;
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过") resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过", @Operation(summary = "测量审核通过",
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。") description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。")
@PostMapping("/{inboundId}/measurements/approve") @PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve( public ApiResponse<FeeSnapshotResponse> approve(
@@ -53,9 +51,9 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) { @Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId); validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved( // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
tenantId, inboundId, user.userId(), user.username(), request.measurementId(), // 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
request.expectedVersion() + 1)); return ApiResponse.success(null);
} }
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
@@ -0,0 +1,102 @@
package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.EvidenceListResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/** 平台端装柜证据、装柜完成和派送完成操作。 */
@Tag(name = "订单装柜与派送")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformLoadingDeliveryController {
@Resource private LoadingDeliveryService service;
@Operation(summary = "新增或修改装柜证据")
@PostMapping("/{inboundId}/loading/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
@BusinessAudit(event = "INBOUND_LOADING_EVIDENCE_UPSERT", resourceType = "INBOUND", resourceId = "#inboundId", description = "维护装柜证据")
public ApiResponse<LoadingActionResponse> evidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpsertEvidenceRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.upsertEvidence(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询装柜证据")
@GetMapping("/{inboundId}/loading/evidence")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ApiResponse<EvidenceListResponse> evidence(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.evidence(tenantId, inboundId));
}
@Operation(summary = "完成装柜")
@PostMapping("/{inboundId}/loading/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:complete')")
@BusinessAudit(event = "INBOUND_COMPLETE_LOADING", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认装柜完成")
public ApiResponse<LoadingActionResponse> completeLoading(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteLoadingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completeLoading(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认派送完成")
@PostMapping("/{inboundId}/delivery/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:delivery:complete')")
@BusinessAudit(event = "INBOUND_COMPLETE_DELIVERY", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认派送完成")
public ApiResponse<LoadingActionResponse> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CompleteDeliveryRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completeDelivery(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA开始拣货")
@PostMapping("/{inboundId}/loading/picking/start")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
@BusinessAudit(event = "INBOUND_START_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "开始PDA拣货")
public ApiResponse<PickingResponse> startPicking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.startPicking(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "PDA完成拣货")
@PostMapping("/{inboundId}/loading/picking/complete")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:edit')")
@BusinessAudit(event = "INBOUND_COMPLETE_PICKING", resourceType = "INBOUND", resourceId = "#inboundId", description = "完成PDA拣货")
public ApiResponse<PickingResponse> completePicking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickingRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.completePicking(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询PDA拣货任务")
@GetMapping("/{inboundId}/loading/picking")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:picking:view')")
public ApiResponse<PickingResponse> picking(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.picking(tenantId, inboundId));
}
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.loading.domain;
import java.util.Set;
/**
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
*/
public final class LoadingEvidenceType {
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
public static final String SEAL = "SEAL";
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
private LoadingEvidenceType() {}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.loading.domain;
public final class PickingStatus {
public static final String PENDING = "PENDING";
public static final String IN_PROGRESS = "IN_PROGRESS";
public static final String COMPLETED = "COMPLETED";
private PickingStatus() {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.loading.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class LoadingRequests {
private LoadingRequests() {}
public record UpsertEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@Positive Long fileId,
@Size(max = 255) String fileName,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
public record CompleteLoadingRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record CompleteDeliveryRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String remark) {}
public record PickingRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@PositiveOrZero Integer expectedCount,
@PositiveOrZero Integer pickedCount,
@Size(max = 1000) String remark) {}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.loading.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class LoadingResponses {
private LoadingResponses() {}
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
Long fileId, String fileName, String sealNo, String remark,
Integer version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record EvidenceListResponse(Long inboundId, List<EvidenceResponse> evidences) {}
public record LoadingActionResponse(String mainStatus, EvidenceListResponse evidences) {}
public record PickingResponse(Long id, Long inboundId, String status, Integer version,
Integer expectedCount, Integer pickedCount, String remark) {}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_evidence")
public class LoadingEvidenceEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String evidenceType;
private Long fileId;
private String fileName;
private String sealNo;
private String remark;
private Integer version;
private Long createdBy;
private String createdByName;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_operation")
public class LoadingOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.loading.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_loading_pick_task")
public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long inboundId;
private String status;
private Integer version;
private Integer expectedCount;
private Integer pickedCount;
private String remark;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingEvidenceMapper extends BaseMapper<LoadingEvidenceEntity> {
@Update("UPDATE fl_loading_evidence SET file_id=#{fileId}, file_name=#{fileName}, seal_no=#{sealNo}, "
+ "remark=#{remark}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND evidence_type=#{evidenceType} "
+ "AND version=#{expectedVersion}")
int updateEvidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("evidenceType") String evidenceType, @Param("fileId") Long fileId,
@Param("fileName") String fileName, @Param("sealNo") String sealNo,
@Param("remark") String remark, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.loading.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingInboundMapper {
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version "
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}")
int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_inbound_order SET process_status='DELIVERED', updated_by=#{operatorId}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADED' AND version=#{expectedVersion}")
int completeDelivery(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingOperationMapper extends BaseMapper<LoadingOperationEntity> {
@Insert("INSERT IGNORE INTO fl_loading_operation "
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("requestHash") String requestHash);
@Update("UPDATE fl_loading_operation SET status='SUCCESS', result_json=#{resultJson}, completed_at=CURRENT_TIMESTAMP(3) "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND status=#{fromStatus} AND version=#{expectedVersion}")
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("remark") String remark, @Param("expectedCount") Integer expectedCount,
@Param("pickedCount") Integer pickedCount,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.loading.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
@Resource private LoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
}
public int completeLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public int completeDelivery(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.completeDelivery(tenantId, inboundId, operatorId, expectedVersion);
}
public LoadingEvidenceEntity findEvidence(long tenantId, long inboundId, String type) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, type));
}
public List<LoadingEvidenceEntity> listEvidence(long tenantId, long inboundId) {
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
String sealNo, String remark, long operatorId, String operatorName, int version) {
return evidences.updateEvidence(tenantId, inboundId, type, fileId, fileName, sealNo, remark,
operatorId, operatorName, version);
}
public LoadingOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<LoadingOperationEntity>()
.eq(LoadingOperationEntity::getTenantId, tenantId)
.eq(LoadingOperationEntity::getInboundId, inboundId)
.eq(LoadingOperationEntity::getOperatorId, operatorId)
.eq(LoadingOperationEntity::getOperation, operation)
.eq(LoadingOperationEntity::getIdempotencyKey, key));
}
public boolean claimOperation(long tenantId, long inboundId, long operatorId, String operation,
String key, String requestHash) {
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key, requestHash) == 1;
}
public void completeOperation(long tenantId, long inboundId, long operatorId, String operation,
String key, String resultJson) {
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
}
public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId));
}
public void createPickTask(LoadingPickTaskEntity task) { picks.insert(task); }
public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId,
String operatorName, String remark, Integer expectedCount,
Integer pickedCount, int version) {
return picks.changeStatus(tenantId, inboundId, from, to, operatorId, operatorName, remark,
expectedCount, pickedCount, version);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.loading.domain.LoadingEvidenceType;
import com.baiyue.firstleg.loading.dto.LoadingRequests.*;
import com.baiyue.firstleg.loading.dto.LoadingResponses.*;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
private static final String COMPLETE_LOADING = "COMPLETE_LOADING";
private static final String COMPLETE_DELIVERY = "COMPLETE_DELIVERY";
@Resource private LoadingRepository repository;
@Resource private InboundStatusHistoryMapper histories;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public LoadingActionResponse upsertEvidence(long tenantId, long inboundId, long operatorId,
String operatorName, String key, UpsertEvidenceRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, hash);
if (replay != null) return replay;
requireLoadingStage(tenantId, inboundId);
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file == null ? request.fileName() : file.fileName();
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
}
@Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteLoadingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
}
@Transactional
public LoadingActionResponse completeDelivery(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteDeliveryRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
if ("DELIVERED".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
if (!"LOADED".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜完成");
if (repository.completeDelivery(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADED", "DELIVERED", COMPLETE_DELIVERY, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_DELIVERY, key,
new LoadingActionResponse("DELIVERED", evidenceResponse(tenantId, inboundId)));
}
public EvidenceListResponse evidence(long tenantId, long inboundId) {
requireLoadingStage(tenantId, inboundId);
return evidenceResponse(tenantId, inboundId);
}
@Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS,
operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "START_PICKING", key,
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
return result;
}
@Transactional
public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId,
task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key,
new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)));
return result;
}
public PickingResponse picking(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return pickResponse(tenantId, inboundId);
}
private PickingResponse pickResponse(long tenantId, long inboundId) {
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) return null;
return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(),
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
if (response == null || !response.successful() || response.data() == null
|| !("LOADING_" + evidenceType).equals(response.data().businessType())) {
throw new BizException(ResultCode.BAD_REQUEST, "证据文件不存在或不属于当前订单");
}
return response.data();
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
private Snapshot requireInbound(long tenantId, long inboundId) {
Snapshot value = repository.findInbound(tenantId, inboundId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
return value;
}
private Snapshot requireLoadingStage(long tenantId, long inboundId) {
Snapshot value = requireInbound(tenantId, inboundId);
if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus())
|| "DELIVERED".equals(value.processStatus()))) {
throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段");
}
return value;
}
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), e.getCreatedAt(), e.getUpdatedAt())).toList();
return new EvidenceListResponse(inboundId, values);
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
}
private LoadingActionResponse complete(long tenantId, long inboundId, long operatorId, String operation,
String key, LoadingActionResponse result) {
try { repository.completeOperation(tenantId, inboundId, operatorId, operation, key, objectMapper.writeValueAsString(result)); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存装柜幂等结果", e); }
return result;
}
private void addHistory(long tenantId, long inboundId, String from, String to, String action, String reason,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId); history.setInboundId(inboundId); history.setFromStatus(from); history.setToStatus(to);
history.setAction(action); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
histories.insert(history);
}
private String hash(Object request) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (Exception e) { throw new IllegalStateException("无法计算幂等请求摘要", e); }
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
}
private static BizException concurrent() { return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试"); }
}
@@ -11,7 +11,8 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> { public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED', SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1 updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
@@ -34,8 +34,6 @@ public class InboundMeasurementService {
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final Set<String> EDITABLE_INBOUND_STATUSES =
Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@Resource @Resource
@@ -58,7 +56,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) { InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
MeasurementBatchCommand command = new MeasurementBatchCommand( MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(), tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
@@ -78,35 +75,42 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) { InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId()); InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion()); requireVersion(request.version(), measurement.getVersion());
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus());
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) { .contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核"); throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
} }
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId()); List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
if (items.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
}
if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片");
}
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId); List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
measurement.setStatus(STATUS_SUBMITTED); if (!alreadyAdvanced) {
if (!repository.updateMeasurement(measurement)) { measurement.setStatus(STATUS_SUBMITTED);
throw concurrentModification(); if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
} }
inbound.setProcessStatus("MEASURE_PENDING"); if (!alreadyAdvanced) {
inbound.setMeasureReviewStatus("PENDING"); inbound.setProcessStatus("MEASURE_PENDING");
inbound.setTotalPackageCount(measurement.getTotalPackageCount()); inbound.setMeasureReviewStatus("PENDING");
inbound.setActualGrossWeightKg(confirmedWeight); inbound.setMeasureCompletedAt(LocalDateTime.now());
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); }
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); if (measurement.getTotalPackageCount() != null) {
inbound.setMeasureCompletedAt(LocalDateTime.now()); inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
if (confirmedWeight != null) {
inbound.setActualGrossWeightKg(confirmedWeight);
}
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
if (measurement.getVolumeWeightKg() != null) {
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
}
inbound.setUpdatedBy(operatorId); inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) { if (!repository.updateInbound(inbound)) {
throw concurrentModification(); throw concurrentModification();
@@ -121,7 +125,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.CreateWeightResultRequest request) { InboundMeasurementRequests.CreateWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
} }
@@ -150,7 +153,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName, long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.CorrectWeightResultRequest request) { InboundMeasurementRequests.CorrectWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion()); requireVersion(request.version(), weight.getVersion());
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit()); String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
@@ -181,7 +183,6 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName, long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.ConfirmWeightResultRequest request) { InboundMeasurementRequests.ConfirmWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion()); requireVersion(request.version(), weight.getVersion());
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
@@ -225,18 +226,12 @@ public class InboundMeasurementService {
} }
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) { private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
if (weights.isEmpty()) { BigDecimal total = weights.stream()
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片"); .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
} .map(InboundWeightResultEntity::getFinalWeightKg)
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) { .filter(java.util.Objects::nonNull)
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
}
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
if (!isPositive(total)) { return isPositive(total) ? total : null;
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
}
return total;
} }
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
@@ -328,12 +323,6 @@ public class InboundMeasurementService {
return weight; return weight;
} }
private void requireEditable(InboundOrderEntity inbound) {
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据");
}
}
private void requireVersion(Integer expected, Integer actual) { private void requireVersion(Integer expected, Integer actual) {
if (expected == null || !expected.equals(actual)) { if (expected == null || !expected.equals(actual)) {
throw concurrentModification(); throw concurrentModification();
@@ -38,7 +38,8 @@ public class MeasurementBatchService {
@Transactional @Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
if (repository.findInbound(command.tenantId(), command.inboundId()) == null) { var inbound = repository.findInbound(command.tenantId(), command.inboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
} }
validateSource(command); validateSource(command);
@@ -50,7 +51,13 @@ public class MeasurementBatchService {
applyTotals(measurement, calculatedItems); applyTotals(measurement, calculatedItems);
repository.insertMeasurement(measurement); repository.insertMeasurement(measurement);
} else { } else {
measurement = requireEditableManualBatch(command); measurement = requireEditableBatch(command);
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料,
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
measurement.setStatus(STATUS_DRAFT);
}
applyHeader(measurement, command); applyHeader(measurement, command);
applyTotals(measurement, calculatedItems); applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) { if (!repository.updateMeasurement(measurement)) {
@@ -90,15 +97,12 @@ public class MeasurementBatchService {
return measurement; return measurement;
} }
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) { private InboundMeasurementEntity requireEditableBatch(MeasurementBatchCommand command) {
InboundMeasurementEntity measurement = repository.findMeasurement( InboundMeasurementEntity measurement = repository.findMeasurement(
command.tenantId(), command.inboundId(), command.measurementId()); command.tenantId(), command.inboundId(), command.measurementId());
if (measurement == null) { if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在"); throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
} }
if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改");
}
if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) { if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) {
throw concurrentModification(); throw concurrentModification();
} }
@@ -20,7 +20,8 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。 * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
* 计费、库位和其他资料不作为本版本的强制门禁。
*/ */
@Service @Service
public class MeasurementReviewService { public class MeasurementReviewService {
@@ -79,7 +80,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName)); request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? "WAIT_PRICING" : "MEASURE_REJECTED", approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId); idempotency.complete(context, inboundId);
} }
@@ -11,15 +11,14 @@ import org.springframework.transaction.annotation.Transactional;
public class MeasurementSubmissionService { public class MeasurementSubmissionService {
@Resource @Resource
private InboundMeasurementService measurements; private InboundMeasurementService measurements;
@Resource
private MeasurementLocationOccupationService locations;
@Transactional @Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit( public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) { InboundMeasurementRequests.SubmitRequest request) {
var result = measurements.submit(tenantId, inboundId, operatorId, request); var result = measurements.submit(tenantId, inboundId, operatorId, request);
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version()); // 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进,
// 仓储资源可在后续作业阶段继续维护。
return result; return result;
} }
} }
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端分柜、预装清单和装柜中主状态推进接口。 */
@Tag(name = "订单分柜与预装")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformSplitPreloadController {
@Resource private SplitPreloadService service;
@Operation(summary = "智能分柜并生成或覆盖预装清单")
@PostMapping("/{inboundId}/split/smart")
@PreAuthorize("hasAuthority('firstleg:inbound:split')")
@BusinessAudit(event = "INBOUND_SMART_SPLIT", resourceType = "INBOUND", resourceId = "#inboundId",
description = "智能分柜")
public ApiResponse<SplitActionResponse> smart(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SmartSplitRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "提交预装清单确认")
@PostMapping("/{inboundId}/preload/confirming")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId",
description = "提交预装清单确认")
public ApiResponse<SplitActionResponse> confirming(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "确认预装清单并进入装柜中")
@PostMapping("/{inboundId}/preload/confirm")
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
@BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认预装清单")
public ApiResponse<SplitActionResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ConfirmPreloadRequest request) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request));
}
@Operation(summary = "查询预装清单")
@GetMapping("/{inboundId}/preload")
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
public ApiResponse<PreloadPlanResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId) {
FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.detail(tenantId, inboundId));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.split.domain;
import java.util.Set;
/** 预装清单子状态,固定为三态。 */
public final class PreloadStatus {
public static final String PENDING = "PENDING";
public static final String CONFIRMING = "CONFIRMING";
public static final String CONFIRMED = "CONFIRMED";
public static final Set<String> VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
private PreloadStatus() {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.split.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/** 分柜与预装清单写入请求。资料字段本版本不做完整性门禁。 */
public final class SplitRequests {
private SplitRequests() {}
public record SmartSplitRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
List<@Valid PreloadItem> items,
@Size(max = 1000) String remark) {}
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
public record PreloadItem(
@Size(max = 64) String containerCode,
@Size(max = 128) String cargoReference,
@Positive Integer packageCount,
@PositiveOrZero BigDecimal volumeCbm,
@Size(max = 500) String remark) {}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.split.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class SplitResponses {
private SplitResponses() {}
public record PreloadPlanResponse(Long id, Long inboundId, Long tenantId, String status,
Integer version, List<SplitRequests.PreloadItem> items,
String remark, LocalDateTime createdAt,
LocalDateTime updatedAt) {}
public record SplitActionResponse(String mainStatus, PreloadPlanResponse preloadPlan) {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.split.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_preload_plan")
public class PreloadPlanEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String status;
private Integer version;
private String itemsJson;
private String remark;
private Long createdBy;
private String createdByName;
private Long updatedBy;
private String updatedByName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.split.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_split_operation")
public class SplitOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String status;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.split.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
@Update("UPDATE fl_preload_plan SET items_json=#{itemsJson}, remark=#{remark}, status='PENDING', "
+ "updated_by=#{operatorId}, updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND version=#{expectedVersion}")
int replace(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("itemsJson") String itemsJson, @Param("remark") String remark,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
@Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, "
+ "updated_by_name=#{operatorName}, version=version+1 "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} "
+ "AND version=#{expectedVersion}")
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.split.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SplitInboundMapper {
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version FROM fl_inbound_order "
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.split.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SplitOperationMapper extends BaseMapper<SplitOperationEntity> {
@Insert("INSERT IGNORE INTO fl_split_operation "
+ "(id,tenant_id,inbound_id,operator_id,operation,idempotency_key,status,created_at) "
+ "VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{operation},#{key},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
@Update("UPDATE fl_split_operation SET status='SUCCESS', result_json=#{resultJson} "
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.split.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper;
import com.baiyue.firstleg.split.mapper.SplitOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@Repository
public class SplitRepository {
@Resource private SplitInboundMapper inbounds;
@Resource private PreloadPlanMapper plans;
@Resource private SplitOperationMapper operations;
public SplitInboundMapper.InboundSnapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
}
public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
}
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
.eq(PreloadPlanEntity::getTenantId, tenantId)
.eq(PreloadPlanEntity::getInboundId, inboundId));
}
public void createPlan(PreloadPlanEntity plan) {
plans.insert(plan);
}
public int replacePlan(long tenantId, long inboundId, String itemsJson, String remark,
long operatorId, String operatorName, int expectedVersion) {
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
}
public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus,
long operatorId, String operatorName, int expectedVersion) {
return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus,
operatorId, operatorName, expectedVersion);
}
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
.eq(SplitOperationEntity::getTenantId, tenantId)
.eq(SplitOperationEntity::getInboundId, inboundId)
.eq(SplitOperationEntity::getOperatorId, operatorId)
.eq(SplitOperationEntity::getOperation, operation)
.eq(SplitOperationEntity::getIdempotencyKey, key));
}
public void saveOperation(SplitOperationEntity operation) {
operations.insert(operation);
}
public boolean claimOperation(long tenantId, long inboundId, long operatorId,
String operation, String key) {
return operations.claim(IdWorker.getId(), tenantId, inboundId, operatorId, operation, key) == 1;
}
public void completeOperation(long tenantId, long inboundId, long operatorId,
String operation, String key, String resultJson) {
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
}
}
@@ -0,0 +1,213 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.domain.PreloadStatus;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.entity.SplitOperationEntity;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot;
import com.baiyue.firstleg.split.repository.SplitRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
@Service
public class SplitPreloadService {
private static final String SMART_SPLIT = "SMART_SPLIT";
private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING";
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
@Resource private SplitRepository repository;
@Resource private InboundStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public SplitActionResponse smartSplit(long tenantId, long inboundId, long operatorId,
String operatorName, String key, SmartSplitRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
String itemsJson = write(request.items() == null ? List.of() : request.items());
if (plan == null) {
if (request.expectedVersion() != 0) throw concurrent();
plan = new PreloadPlanEntity();
plan.setTenantId(tenantId);
plan.setInboundId(inboundId);
plan.setStatus(PreloadStatus.PENDING);
plan.setVersion(0);
plan.setItemsJson(itemsJson);
plan.setRemark(request.remark());
plan.setCreatedBy(operatorId);
plan.setCreatedByName(operatorName);
plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName);
repository.createPlan(plan);
} else if (repository.replacePlan(tenantId, inboundId, itemsJson, request.remark(),
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
new SplitActionResponse("WAIT_SPLIT", result));
}
@Transactional
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId,
String operatorName, String key,
ConfirmPreloadRequest request) {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if ("LOADING".equals(inbound.processStatus())) {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
}
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
public PreloadPlanResponse detail(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return response(requirePlan(tenantId, inboundId));
}
private SplitActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key)) return null;
SplitOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !"SUCCESS".equals(existing.getStatus())
|| !StringUtils.hasText(existing.getResultJson())) {
throw new BizException(ResultCode.CONFLICT, "相同幂等请求正在处理中,请稍后重试");
}
try {
return objectMapper.readValue(existing.getResultJson(), SplitActionResponse.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法读取分柜幂等结果", e);
}
}
private SplitActionResponse complete(long tenantId, long inboundId, long operatorId,
String operation, String key, SplitActionResponse result) {
repository.completeOperation(tenantId, inboundId, operatorId, operation, key, write(result));
return result;
}
private InboundSnapshot requireInbound(long tenantId, long inboundId) {
InboundSnapshot inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
return inbound;
}
private PreloadPlanEntity requirePlan(long tenantId, long inboundId) {
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "预装清单不存在");
return plan;
}
private static void requireStatus(InboundSnapshot inbound, String expected, String message) {
if (!expected.equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, message);
}
private void addHistory(long tenantId, long inboundId, String from, String to, String action,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId);
history.setInboundId(inboundId);
history.setFromStatus(from);
history.setToStatus(to);
history.setAction(action);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
histories.insert(history);
}
private PreloadPlanResponse response(PreloadPlanEntity plan) {
if (plan == null) return null;
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
plan.getStatus(), plan.getVersion(), readItems(plan.getItemsJson()), plan.getRemark(),
plan.getCreatedAt(), plan.getUpdatedAt());
}
private List<PreloadItem> readItems(String json) {
if (!StringUtils.hasText(json)) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<PreloadItem>>() {});
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法读取预装清单", e);
}
}
private String write(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("无法保存预装清单", e);
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且不能超过128个字符");
}
}
private static BizException concurrent() {
return new BizException(ResultCode.CONFLICT, "数据已被其他人修改,请刷新后重试");
}
}
@@ -0,0 +1,62 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/* 装柜证据和派送完成;不设置证据完整度门禁,允许后续覆盖编辑。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1..4/SEAL',
file_id BIGINT UNSIGNED NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type),
KEY idx_loading_evidence_time (tenant_id, inbound_id, updated_at),
CONSTRAINT fk_loading_evidence_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单装柜四步照片和铅封证据';
CREATE TABLE IF NOT EXISTS fl_loading_operation (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(48) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_loading_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_loading_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED',
version INT NOT NULL DEFAULT 0,
expected_count INT UNSIGNED NULL,
picked_count INT UNSIGNED NULL,
remark VARCHAR(1000) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id),
KEY idx_loading_pick_status (tenant_id, status, updated_at),
CONSTRAINT fk_loading_pick_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='PDA拣货任务及结果';
@@ -0,0 +1,39 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/* 三期分柜与预装清单;独立于现有测量、计费表,可重复部署。 */
CREATE TABLE IF NOT EXISTS fl_preload_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
items_json JSON NOT NULL COMMENT '可编辑分柜明细快照',
remark VARCHAR(1000) NULL COMMENT '预装备注',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id),
KEY idx_preload_status (tenant_id, status, updated_at),
CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单分柜预装清单';
CREATE TABLE IF NOT EXISTS fl_split_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引';
@@ -0,0 +1,66 @@
package com.baiyue.firstleg.loading.service;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class LoadingDeliveryServiceTest {
@Mock LoadingRepository repository;
@Mock InboundStatusHistoryMapper histories;
@Mock LoadingEvidenceFileClient files;
@InjectMocks LoadingDeliveryService service;
@BeforeEach
void injectObjectMapper() throws Exception {
var field = LoadingDeliveryService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, new ObjectMapper());
var token = LoadingDeliveryService.class.getDeclaredField("internalToken");
token.setAccessible(true);
token.set(service, "token");
}
@Test
void completesLoadingWithoutEvidenceGate() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null);
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2",
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", null, "photo.jpg", null, "updated"));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).createEvidence(any(LoadingEvidenceEntity.class));
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
}
@@ -138,7 +138,7 @@ class InboundMeasurementServiceTest {
} }
@Test @Test
void submitRejectsAnyUnconfirmedWeightWithoutChangingState() { void submitDoesNotBlockOnUnconfirmedWeight() {
InboundOrderEntity inbound = inbound("MEASURING"); InboundOrderEntity inbound = inbound("MEASURING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity(); InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L); measurement.setId(30L);
@@ -151,13 +151,43 @@ class InboundMeasurementServiceTest {
when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of( when(repository.listWeights(1L, 10L)).thenReturn(List.of(
weight(20L, "20", "KG", "PENDING_CONFIRM", 0))); weight(20L, "20", "KG", "PENDING_CONFIRM", 0)));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
assertThatThrownBy(() -> service.submit(1L, 10L, 9L, InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0))) new InboundMeasurementRequests.SubmitRequest(30L, 0));
.isInstanceOf(BizException.class)
.hasMessageContaining("必须先完成确认"); assertThat(result.processStatus()).isEqualTo("MEASURE_PENDING");
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateMeasurement(measurement);
verify(repository).updateInbound(inbound);
}
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
measurement.setVersion(2);
measurement.setTotalPackageCount(4);
measurement.setTotalVolumeCbm(new BigDecimal("2.0000"));
measurement.setVolumeWeightKg(new BigDecimal("334.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of());
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any()); verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any()); verify(repository).updateInbound(inbound);
} }
private InboundOrderEntity inbound(String status) { private InboundOrderEntity inbound(String status) {
@@ -104,6 +104,31 @@ class MeasurementBatchServiceTest {
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
} }
@Test
void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() {
InboundMeasurementEntity existing = new InboundMeasurementEntity();
existing.setId(100L);
existing.setTenantId(1L);
existing.setInboundId(10L);
existing.setSourceType("MANUAL");
existing.setStatus("APPROVED");
existing.setVersion(3);
when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing);
when(repository.updateMeasurement(existing)).thenReturn(true);
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"),
new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE)));
InboundMeasurementEntity saved = service.saveBatch(command);
assertThat(saved.getStatus()).isEqualTo("DRAFT");
verify(repository).updateMeasurement(existing);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), any());
}
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item( return new MeasurementBatchCommand.Item(
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -7,10 +7,12 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -70,7 +72,11 @@ class MeasurementReviewServiceTest {
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true); verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true);
verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED"); verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
verify(fixture.repository).insertReview(any()); verify(fixture.repository).insertReview(any());
verify(fixture.repository).insertHistory(any()); ArgumentCaptor<InboundStatusHistoryEntity> history =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
} }
@Test @Test
@@ -9,10 +9,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SpringJUnitConfig(MeasurementSubmissionService.class) @SpringJUnitConfig(MeasurementSubmissionService.class)
@@ -38,7 +37,7 @@ class MeasurementSubmissionServiceTest {
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).occupy(10001L, 88L, 9L, 30L, 3); verifyNoInteractions(locations);
} }
@Test @Test
@@ -47,10 +46,7 @@ class MeasurementSubmissionServiceTest {
var submitted = new InboundMeasurementResponses.MeasurementSubmitted( var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
doThrow(new IllegalStateException("warehouse unavailable")) assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
.when(locations).occupy(10001L, 88L, 9L, 30L, 3); verifyNoInteractions(locations);
assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request))
.isInstanceOf(IllegalStateException.class);
} }
} }
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSplitPreloadControllerContractTest {
@Test
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class);
assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertThat(PlatformSplitPreloadController.class
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
long.class, long.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:inbound:preload-view')");
}
private void assertAuthority(String name, String expected, Class<?> requestType) throws Exception {
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
String.class, requestType);
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.SplitInboundMapper.InboundSnapshot;
import com.baiyue.firstleg.split.repository.SplitRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest {
@Mock SplitRepository repository;
@Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void objectMapper() throws Exception {
var field = SplitPreloadService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, objectMapper);
}
@Test
void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request);
assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).createPlan(any(PreloadPlanEntity.class));
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(repository).beginLoading(10, 20, 7, 4);
}
}
@@ -61,8 +61,8 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey)); return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
} }
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿") @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料")
@Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。") @Operation(summary = "平台编辑订单", description = "在待审核阶段编辑订单资料;编辑不会将订单退回草稿状态。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"),
@@ -75,7 +75,7 @@ public class PlatformFirstLegOrderController {
@Parameter(description = "订单 ID", required = true) @PathVariable long id, @Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) { @Valid @RequestBody OrderRequests.SaveRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId); long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.updateDraft(scopedTenant, user.userId(), id, request); service.updateSubmittedOrder(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -60,16 +60,37 @@ public class OrderApplicationService {
@Transactional @Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, false);
}
/**
* 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。
* 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)},
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
*/
@Transactional
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, true);
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id); OrderEntity order = require(tenantId, id);
validatePrototypeFields(request); validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request); var snapshots = masterDataGuard.validate(tenantId, request);
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) { OrderStatus before = order.getStatus();
throw new BizException("Only draft or rejected orders can be edited"); if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException("当前订单状态不允许编辑订单资料");
} }
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
repository.replaceDraftChildren(tenantId, id); repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request); saveChildren(tenantId, userId, id, request);
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
} }
@Transactional @Transactional
@@ -78,6 +78,48 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT)); verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
} }
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request();
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateSubmittedOrder(10001L, 2L, 1L, request);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).replaceDraftChildren(10001L, 1L);
verify(repository).insertHistory(argThat(history -> history.getFromStatus() == OrderStatus.SUBMITTED
&& history.getToStatus() == OrderStatus.SUBMITTED
&& "UPDATE".equals(history.getAction())));
}
@Test
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateDraft(10001L, 2L, 1L, request()))
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许编辑");
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test @Test
void unapprovedTenantStillCannotSubmitDraft() { void unapprovedTenantStillCannotSubmitDraft() {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
+9 -7
View File
@@ -2,9 +2,9 @@
## 职责边界 ## 职责边界
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 - 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。 - 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
## 快速索引 ## 快速索引
@@ -13,20 +13,22 @@
| 调用方接入指南 | `README.md` | | 调用方接入指南 | `README.md` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` | | 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | | 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) | | 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` | | 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` | | 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | | 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` | | 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` |
| PDF 参数配置 | `config/PrintPdfProperties.java` | | PDF 参数配置 | `config/PrintPdfProperties.java` |
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | | 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | | 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` |
| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | | 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` |
## 修改与验证 ## 修改与验证
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。 - 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。
- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。 - 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。 - 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。 - 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
+161 -7
View File
@@ -1,22 +1,25 @@
# Print PDF 服务接入指南 # Print PDF 内部调用契约
本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。 本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。Print 是无数据库的内部渲染服务:调用方提交完整打印数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
完整机器可读契约见 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml),版式与技术决策见 [`../docs/2026-08-04-无状态PDF打印服务设计.md`](../docs/2026-08-04-无状态PDF打印服务设计.md)。 机器可读 HTTP 契约以 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml) 为准;本文件补充调用方必须实现、但 OpenAPI 无法完整表达的权限、字段映射、响应分支和日志约束。调用方必须自行声明 DTO,不得依赖 Print 模块的 Java 实现类。
## 1. 调用边界 ## 1. 调用边界
- 服务发现名:`print-service`。 - 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 - 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
- 本地直连:`http://127.0.0.1:9207`。 - 本地直连:`http://127.0.0.1:9207`。
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。 - 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。 - 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。 - Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。 调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
## 2. 请求契约 ## 2. 请求契约
### 2.1 入仓标签
```http ```http
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1 POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
Content-Type: application/json Content-Type: application/json
@@ -69,6 +72,57 @@ X-Request-Id: <optional-correlation-id>
未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。 未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
### 2.2 海运 A4 单据
```json
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {
"widthMm": 210,
"heightMm": 297
},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [
{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613...",
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS",
"jobNumber": "BYSE2607044"
},
"cargoRows": [
{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}
]
}
]
}
```
| 字段 | 必填 | 规则 |
|---|---|---|
| `templateCode` | 是 | `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 或 `BILL_OF_LADING_CONFIRMATION_A4_V1` |
| `pageSpec.widthMm` | 是 | 固定数值 `210` |
| `pageSpec.heightMm` | 是 | 固定数值 `297` |
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 |
| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 |
托运书模板支持的完整 `fields` 键:`toCompany`、`issuerNameCn`、`issuerNameEn`、`issuerAddress`、`issuerPostalCode`、`issuerTel`、`issuerFax`、`shipper`、`consignee`、`notifyParty`、`picName`、`picTel`、`picFax`、`documentName`、`documentTel`、`documentFax`、`jobNumber`、`etdDate`、`serviceTerm`、`freightTerm`、`apCode`、`apName`、`cargoQuantity`、`transshipment`、`partialShipment`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`、`remark`、`carrier`、`bookingAgent`、`printDate`。
提单确认件模板支持的完整 `fields` 键:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`ensAmsCutoffDate`、`customsCutoffDate`、`expectedDepartureDate`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`finalDestination`、`serviceTerm`、`freightTerm`、`shipperLoadCountSeal`、`containerSpec`、`shippedOnBoard`、`draftText`、`totalPackagesInWords`、`containerSummary`、`notes`、`issuePlace`、`issueDate`。
调用方 MUST 只发送所选模板支持的键。当前服务会忽略未知键而不报错,因此调用方必须通过单元测试固定字段映射,避免拼写错误生成缺字段 PDF。空值字段可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。
未传 `downloadFileName` 时,服务使用 `freight-a4-documents-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
## 3. 响应处理 ## 3. 响应处理
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200: 调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
@@ -114,6 +168,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId, @RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request); @RequestBody InboundMarkPdfRequest request);
@PostMapping(value = "/freight-a4-documents", consumes = "application/json")
ResponseEntity<byte[]> generateFreightA4Documents(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody FreightA4PdfRequest request);
record InboundMarkPdfRequest( record InboundMarkPdfRequest(
String templateCode, String templateCode,
PageSpec pageSpec, PageSpec pageSpec,
@@ -128,6 +188,22 @@ public interface PrintPdfClient {
String cartonText, String cartonText,
String routeText, String routeText,
String qrContent) {} String qrContent) {}
record FreightA4PdfRequest(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<FreightDocument> documents) {}
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
record CargoRow(
String marksAndNos,
String quantity,
String packaging,
String descriptionOfGoods,
String grossWeight,
String measurement) {}
} }
``` ```
@@ -176,13 +252,91 @@ spring:
- 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。 - 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。 - 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。 - 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。 - 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、完整 `documents[]`、二维码内容或 PDF 二进制。
## 6. 联调检查清单 ## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP`。 1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。 2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。 3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
4. 提交多页并检查页序与数组顺序一致。 4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。 5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。 6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
## 7. AI 实施与验证清单
修改其他业务模块接入 Print 时,按以下顺序实施,不得从 Admin 浏览器直连 Print:
1. 从 OpenAPI 和本文件在调用模块内声明请求 DTO 与 Feign Client;不得新增对 `print-service` Java 包的编译依赖。
2. 在调用模块的 Service 中完成权限、客户归属和数据范围校验,再从已有订单、客户、航线及货物数据自动映射打印字段。
3. `labels[]`、`documents[]` 和 `cargoRows` 顺序必须由调用模块明确决定;A4 V1 的 `cargoRows` 只能包含 0 或 1 项。
4. 同时处理 `application/pdf`、HTTP 200 的 `application/json` 业务错误及 HTTP 413;不得把任意 HTTP 200 当作成功 PDF。
5. 只向浏览器转发安全响应头,不得转发或记录 `X-Internal-Service-Token`,不得记录完整打印请求或 PDF 二进制。
6. 为字段映射、PDF/JSON 响应分支、`A0403`、`A0422`、`A0429`、HTTP 413 和超时增加调用模块测试。
只验证 Print 本身时,从 `baiyue-firstleg/` 构建并启动单个服务;不需要启动业务服务、Nacos 或数据库:
```powershell
mvn -pl print -am package -DskipTests
$env:PRINT_PORT="9207"
$env:SPRING_PROFILES_ACTIVE="dev"
$env:INTERNAL_SERVICE_TOKEN="print-local-contract-test-token-20260806"
$env:SPRING_CLOUD_NACOS_CONFIG_ENABLED="false"
$env:NACOS_DISCOVERY_ENABLED="false"
$env:NACOS_REGISTER_ENABLED="false"
java -jar print\target\print-service-1.0.0-SNAPSHOT.jar
```
启动后向 `POST http://127.0.0.1:9207/internal/print/v1/pdfs/freight-a4-documents` 发送 `Content-Type: application/json`、`Accept: application/pdf, application/json`、上述内部令牌及任意 `X-Request-Id`。托运书验证请求体:
```json
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {
"widthMm": 210,
"heightMm": 297
},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [
{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"issuerNameCn": "浙江柏悦贸易有限公司(香港)",
"issuerNameEn": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
"issuerAddress": "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室",
"issuerPostalCode": "322000",
"issuerTel": "0579-85875557",
"issuerFax": "0579-85601277",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557",
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354",
"notifyParty": "SAME AS CONSIGNEE",
"picName": "苏天翔",
"jobNumber": "BYSE2607044",
"etdDate": "2026/07/24",
"serviceTerm": "CY-CY",
"freightTerm": "FREIGHT PREPAID",
"cargoQuantity": "1*20GP",
"portOfLoading": "NINGBO",
"portOfDischarge": "EL DEKHEILA",
"placeOfDelivery": "EL DEKHEILA",
"totalPackagesInWords": "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.",
"carrier": "万海航运(香港)股份有限公司",
"bookingAgent": "宁波胜泽远航国际物流有限公司",
"printDate": "2026 年 07 月 16 日"
},
"cargoRows": [
{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}
]
}
]
}
```
提单确认件必须把 `templateCode` 改为 `BILL_OF_LADING_CONFIRMATION_A4_V1`,并仅传上一节列出的提单确认件字段。成功响应必须是 `Content-Type: application/pdf`;`application/json` 必须按响应体 `code/message` 作为失败处理。
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext; import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService; import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -27,6 +29,7 @@ public class InternalPdfController {
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count"; static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
@Resource private InboundMarkPdfService pdfService; @Resource private InboundMarkPdfService pdfService;
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private InternalServiceTokenGuard tokenGuard; @Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -58,4 +61,34 @@ public class InternalPdfController {
return response.body(content); return response.body(content);
} }
@PostMapping(value = "/freight-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateFreightA4Documents(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody FreightA4PdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
FreightA4PdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(),
"freight-a4-documents-" + requestId + ".pdf", request.documents());
}
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(effectiveRequest);
byte[] content = pdf.content();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(pdf.fileName(), StandardCharsets.UTF_8)
.build();
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header("X-Content-Type-Options", "nosniff")
.header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF)
.contentLength(content.length);
return response.body(content);
}
} }
@@ -28,7 +28,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component @Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10) @Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class PrintRequestSizeFilter extends OncePerRequestFilter { public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks"; private static final java.util.Set<String> PATHS = java.util.Set.of(
"/internal/print/v1/pdfs/inbound-marks",
"/internal/print/v1/pdfs/freight-a4-documents");
@Resource private PrintPdfProperties properties; @Resource private PrintPdfProperties properties;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) { if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATHS.contains(request.getRequestURI())) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
@@ -0,0 +1,47 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/** A4 sea-freight document PDF request contract. */
public final class FreightA4PdfDtos {
public static final String SHIPPING_INSTRUCTION_TEMPLATE = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
public static final String BILL_CONFIRMATION_TEMPLATE = "BILL_OF_LADING_CONFIRMATION_A4_V1";
private FreightA4PdfDtos() {
}
public record Request(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Document> documents
) {
}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
public record Document(
Map<String, String> fields,
List<CargoRow> cargoRows
) {
public String field(String key) {
if (fields == null) {
return "";
}
return fields.getOrDefault(key, "");
}
}
public record CargoRow(
String marksAndNos,
String quantity,
String packaging,
String descriptionOfGoods,
String grossWeight,
String measurement
) {
}
}
@@ -0,0 +1,333 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.FreightA4PdfDtos;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component;
/** Fixed A4 renderers for sea-freight documents. */
@Component
public class FreightA4Renderer {
public static final double PAGE_WIDTH_MM = 210d;
public static final double PAGE_HEIGHT_MM = 297d;
private static final float PAGE_WIDTH_PT = PdfUnits.mmToPt(PAGE_WIDTH_MM);
private static final float PAGE_HEIGHT_PT = PdfUnits.mmToPt(PAGE_HEIGHT_MM);
private static final float SHIPPING_TEXT = 6.2f;
private static final float SHIPPING_SMALL = 5.8f;
private static final float BILL_TEXT = 7.2f;
private static final float BILL_SMALL = 6.8f;
public void renderPage(PDDocument document, PDFont font, String templateCode,
FreightA4PdfDtos.Document data) throws IOException {
PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH_PT, PAGE_HEIGHT_PT));
page.setRotation(0);
document.addPage(page);
PdfUnits units = new PdfUnits(PAGE_HEIGHT_MM);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
stream.setNonStrokingColor(0, 0, 0);
stream.setStrokingColor(0, 0, 0);
if (FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)) {
renderShippingInstruction(stream, units, font, data);
} else if (FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE.equals(templateCode)) {
renderBillConfirmation(stream, units, font, data);
} else {
throw new IllegalArgumentException("unsupported A4 freight template");
}
}
}
private void renderShippingInstruction(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
text(stream, units, font, "Sea Freight Shipping Instructions", 12, 15, 18);
text(stream, units, font, "To: " + data.field("toCompany"), SHIPPING_TEXT, 15, 24);
rect(stream, 15, 29, 180, 250);
line(stream, 113, 29, 113, 155);
line(stream, 15, 64, 113, 64);
line(stream, 15, 99, 113, 99);
line(stream, 15, 134, 113, 134);
line(stream, 15, 147, 113, 147);
line(stream, 64, 134, 64, 155);
wrapped(stream, units, font, "Shipper(Full Name & Address)(托运人):", SHIPPING_TEXT, 17, 33, 92, 4, 5);
wrapped(stream, units, font, data.field("shipper"), SHIPPING_TEXT, 17, 39, 92, 5, 5);
wrapped(stream, units, font, "Consignee(收货人):", SHIPPING_TEXT, 17, 68, 92, 4, 5);
wrapped(stream, units, font, data.field("consignee"), SHIPPING_TEXT, 17, 74, 92, 5, 5);
wrapped(stream, units, font, "Notify Party(通知人):", SHIPPING_TEXT, 17, 103, 92, 4, 5);
wrapped(stream, units, font, data.field("notifyParty"), SHIPPING_TEXT, 17, 109, 92, 4, 5);
text(stream, units, font, "Ocean Vessel/Voy(船名航次):", SHIPPING_TEXT, 17, 139);
text(stream, units, font, data.field("oceanVesselVoyage"), SHIPPING_TEXT, 17, 145.5);
text(stream, units, font, "Port of Loading(起运港):", SHIPPING_TEXT, 66, 139);
text(stream, units, font, data.field("portOfLoading"), SHIPPING_TEXT, 66, 145.5);
text(stream, units, font, "Part of Discharge(卸货港):", SHIPPING_TEXT, 17, 150);
text(stream, units, font, data.field("portOfDischarge"), SHIPPING_TEXT, 17, 154);
text(stream, units, font, "Place of Delivery(交货地):", SHIPPING_TEXT, 66, 150);
text(stream, units, font, data.field("placeOfDelivery"), SHIPPING_TEXT, 66, 154);
renderShippingRightPanel(stream, units, font, data);
renderShippingCargo(stream, units, font, data);
}
private void renderShippingRightPanel(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
line(stream, 113, 76, 195, 76);
line(stream, 113, 89, 195, 89);
line(stream, 113, 102, 195, 102);
for (double y : new double[]{109, 116, 123, 130, 137, 144, 151}) {
line(stream, 113, y, 195, y);
}
line(stream, 135, 76, 135, 102);
line(stream, 135, 82.5, 195, 82.5);
line(stream, 135, 95.5, 195, 95.5);
centered(stream, units, font, data.field("issuerNameCn"), 12, 154, 37);
centered(stream, units, font, data.field("issuerNameEn"), SHIPPING_TEXT, 154, 45);
wrapped(stream, units, font, data.field("issuerAddress"), SHIPPING_TEXT, 114, 52, 78, 2, 4.5);
text(stream, units, font, "邮编:" + data.field("issuerPostalCode"), SHIPPING_TEXT, 114, 59);
text(stream, units, font, "TEL:" + data.field("issuerTel") + " | FAX:" + data.field("issuerFax"),
5.2f, 114, 63.5);
centered(stream, units, font, "出口货物托运单", 12, 154, 72);
text(stream, units, font, "PIC(负责人):", SHIPPING_TEXT, 114, 80);
text(stream, units, font, data.field("picName"), SHIPPING_TEXT, 114, 86);
text(stream, units, font, "TEL(电话): " + data.field("picTel"), SHIPPING_TEXT, 136, 80);
text(stream, units, font, "FAX(传真): " + data.field("picFax"), SHIPPING_TEXT, 136, 87);
text(stream, units, font, "Document:", SHIPPING_TEXT, 114, 94);
text(stream, units, font, data.field("documentName"), SHIPPING_TEXT, 114, 100);
text(stream, units, font, "TEL(电话): " + data.field("documentTel"), SHIPPING_TEXT, 136, 94);
text(stream, units, font, "FAX(传真): " + data.field("documentFax"), SHIPPING_TEXT, 136, 101);
text(stream, units, font, "Job Nbr(委托单号): " + data.field("jobNumber"), SHIPPING_TEXT, 114, 107);
text(stream, units, font, "ETD(开航日期): " + data.field("etdDate"), SHIPPING_TEXT, 114, 114);
text(stream, units, font, "Service Term(运输条款):" + data.field("serviceTerm"), SHIPPING_TEXT, 114, 121);
text(stream, units, font, "Freight Term(运费条款):" + data.field("freightTerm"), SHIPPING_TEXT, 114, 128);
text(stream, units, font, "AP Code: " + data.field("apCode"), SHIPPING_TEXT, 114, 135);
text(stream, units, font, "AP Name: " + data.field("apName"), SHIPPING_TEXT, 114, 142);
text(stream, units, font, "Cargo Quantity(货量描述): " + data.field("cargoQuantity"), SHIPPING_TEXT, 114, 149);
text(stream, units, font, "T/S(可否转船): " + data.field("transshipment")
+ " Batch(可否分批): " + data.field("partialShipment"), SHIPPING_TEXT, 114, 153.5);
}
private void renderShippingCargo(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
double y = 155;
line(stream, 15, y, 195, y);
line(stream, 15, 166, 195, 166);
line(stream, 15, 229, 195, 229);
line(stream, 15, 237, 195, 237);
line(stream, 130, 237, 130, 279);
line(stream, 130, 259, 195, 259);
for (double x : new double[]{50, 88, 137, 166}) {
line(stream, x, 155, x, 166);
}
centered(stream, units, font, "Marks & Nos\n(标志和号码)", SHIPPING_TEXT, 32.5, 160);
centered(stream, units, font, "Quantity & Packaging\n(件数及包装)", SHIPPING_TEXT, 69, 160);
centered(stream, units, font, "Description of Goods(品名)", SHIPPING_TEXT, 112.5, 161);
centered(stream, units, font, "Gross Weight(Kilos)\n毛重(公斤)", SHIPPING_SMALL, 151.5, 160);
centered(stream, units, font, "Measurement(CBM)\n体积(立方米)", SHIPPING_SMALL, 180.5, 160);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.marksAndNos()), SHIPPING_TEXT, 17, 171);
text(stream, units, font, value(row.quantity()), SHIPPING_TEXT, 55, 171);
text(stream, units, font, value(row.packaging()), SHIPPING_TEXT, 64, 171);
wrapped(stream, units, font, value(row.descriptionOfGoods()), SHIPPING_TEXT, 88, 171, 47, 5, 5);
text(stream, units, font, value(row.grossWeight()), SHIPPING_TEXT, 147, 171);
text(stream, units, font, value(row.measurement()), SHIPPING_TEXT, 177, 171);
text(stream, units, font, "Total Packages(in words)(件数大写):" + data.field("totalPackagesInWords"),
SHIPPING_TEXT, 17, 234);
text(stream, units, font, "Ramark(备注):", SHIPPING_TEXT, 20, 245);
wrapped(stream, units, font, data.field("remark"), SHIPPING_TEXT, 20, 252, 105, 5, 5);
text(stream, units, font, "Carrier(船公司):", SHIPPING_TEXT, 135, 247);
wrapped(stream, units, font, data.field("carrier"), SHIPPING_TEXT, 135, 254, 55, 2, 5);
text(stream, units, font, "Booking Agent(订舱代理):", SHIPPING_TEXT, 135, 266);
wrapped(stream, units, font, data.field("bookingAgent"), SHIPPING_TEXT, 135, 273, 55, 2, 5);
text(stream, units, font, "打印于: " + data.field("printDate"), SHIPPING_TEXT, 149, 287);
}
private void renderBillConfirmation(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
centered(stream, units, font, "提单确认件", 15, 105, 40);
wrapped(stream, units, font, data.field("shipper"), BILL_TEXT, 10, 64, 72, 5, 5);
wrapped(stream, units, font, data.field("consignee"), BILL_TEXT, 10, 95, 84, 5, 5);
wrapped(stream, units, font, data.field("notifyParty"), BILL_TEXT, 10, 126, 72, 3, 5);
text(stream, units, font, data.field("bookingNumber"), BILL_TEXT, 148, 64);
text(stream, units, font, "ENS/AMS 截单: " + data.field("ensAmsCutoffDate"), BILL_TEXT, 148, 73);
text(stream, units, font, "截关日期: " + data.field("customsCutoffDate"), BILL_TEXT, 148, 82);
text(stream, units, font, "预计开航日期: " + data.field("expectedDepartureDate"), BILL_TEXT, 148, 91);
text(stream, units, font, data.field("oceanVesselVoyage"), BILL_TEXT, 10, 155);
text(stream, units, font, data.field("portOfLoading"), BILL_TEXT, 61, 161);
text(stream, units, font, data.field("portOfDischarge"), BILL_TEXT, 10, 170);
text(stream, units, font, data.field("placeOfDelivery"), BILL_TEXT, 61, 170);
text(stream, units, font, data.field("finalDestination"), BILL_TEXT, 114, 155);
text(stream, units, font, data.field("serviceTerm"), BILL_TEXT, 128, 170);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.quantity()) + " " + value(row.packaging()), BILL_TEXT, 44, 179);
text(stream, units, font, "SAID TO CONTAIN(S)", BILL_TEXT, 74, 179);
text(stream, units, font, value(row.grossWeight()), BILL_TEXT, 156, 179);
text(stream, units, font, value(row.measurement()), BILL_TEXT, 183, 179);
text(stream, units, font, value(row.marksAndNos()), BILL_TEXT, 9, 187);
wrapped(stream, units, font, value(row.descriptionOfGoods()), BILL_SMALL, 73, 186, 83, 8, 4.25);
text(stream, units, font, data.field("freightTerm"), BILL_SMALL, 87, 221);
text(stream, units, font, data.field("shipperLoadCountSeal"), BILL_SMALL, 72, 227);
text(stream, units, font, data.field("containerSpec"), BILL_SMALL, 72, 232);
text(stream, units, font, "SHIPPED ON BOARD:", BILL_SMALL, 163, 195);
text(stream, units, font, data.field("shippedOnBoard"), BILL_SMALL, 169, 202);
centered(stream, units, font, data.field("draftText"), 16, 158, 225);
centered(stream, units, font, data.field("totalPackagesInWords"), BILL_SMALL, 105, 238);
text(stream, units, font, data.field("containerSummary"), BILL_SMALL, 10, 247);
wrapped(stream, units, font, data.field("notes"), BILL_SMALL, 10, 278, 130, 3, 5);
text(stream, units, font, data.field("issuePlace"), BILL_SMALL, 140, 288);
text(stream, units, font, data.field("issueDate"), BILL_SMALL, 168, 288);
}
private FreightA4PdfDtos.CargoRow firstCargoRow(FreightA4PdfDtos.Document data) {
if (data.cargoRows() == null || data.cargoRows().isEmpty() || data.cargoRows().get(0) == null) {
return new FreightA4PdfDtos.CargoRow("", "", "", "", "", "");
}
return data.cargoRows().get(0);
}
private void text(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double xMm, double baselineMmFromTop) throws IOException {
String value = value(text).replace('\n', ' ');
if (value.isEmpty()) {
return;
}
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(PdfUnits.mmToPt(xMm), units.topDownMmToPdfY(baselineMmFromTop));
stream.showText(value);
stream.endText();
}
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double centerXmm, double baselineMmFromTop) throws IOException {
String value = value(text);
if (value.isEmpty()) {
return;
}
String[] lines = value.split("\\n", -1);
double y = baselineMmFromTop - (lines.length - 1) * 2.2;
for (String line : lines) {
float width = font.getStringWidth(line) / 1000f * fontSize;
float x = PdfUnits.mmToPt(centerXmm) - width / 2f;
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, units.topDownMmToPdfY(y));
stream.showText(line);
stream.endText();
y += 5;
}
}
private void wrapped(PDPageContentStream stream, PdfUnits units, PDFont font, String text, float fontSize,
double xMm, double firstBaselineMmFromTop, double widthMm, int maxLines,
double lineHeightMm) throws IOException {
List<String> lines = wrap(font, fontSize, value(text), PdfUnits.mmToPt(widthMm), maxLines);
double y = firstBaselineMmFromTop;
for (String line : lines) {
text(stream, units, font, line, fontSize, xMm, y);
y += lineHeightMm;
}
}
private List<String> wrap(PDFont font, float fontSize, String text, float maxWidthPt, int maxLines)
throws IOException {
List<String> lines = new ArrayList<>();
if (text.isEmpty() || maxLines < 1) {
return lines;
}
for (String sourceLine : text.split("\\n", -1)) {
StringBuilder current = new StringBuilder();
for (String token : sourceLine.split("(?<=\\s)|(?=\\s)")) {
if (token.isEmpty()) {
continue;
}
String candidate = current + token;
if (!current.isEmpty() && width(font, fontSize, candidate) > maxWidthPt) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
current.setLength(0);
candidate = token.stripLeading();
}
if (width(font, fontSize, candidate) > maxWidthPt && current.isEmpty()) {
for (String broken : breakLongToken(font, fontSize, candidate, maxWidthPt)) {
lines.add(broken);
if (lines.size() >= maxLines) {
return lines;
}
}
} else {
current.setLength(0);
current.append(candidate);
}
}
if (!current.isEmpty()) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
}
}
return lines;
}
private List<String> breakLongToken(PDFont font, float fontSize, String token, float maxWidthPt)
throws IOException {
List<String> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int offset = 0; offset < token.length(); ) {
int codePoint = token.codePointAt(offset);
String next = current + new String(Character.toChars(codePoint));
if (!current.isEmpty() && width(font, fontSize, next) > maxWidthPt) {
lines.add(current.toString());
current.setLength(0);
}
current.appendCodePoint(codePoint);
offset += Character.charCount(codePoint);
}
if (!current.isEmpty()) {
lines.add(current.toString());
}
return lines;
}
private float width(PDFont font, float fontSize, String text) throws IOException {
return font.getStringWidth(text) / 1000f * fontSize;
}
private void rect(PDPageContentStream stream, double xMm, double yMm, double widthMm, double heightMm)
throws IOException {
stream.setLineWidth(0.5f);
stream.addRect(PdfUnits.mmToPt(xMm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - yMm - heightMm),
PdfUnits.mmToPt(widthMm), PdfUnits.mmToPt(heightMm));
stream.stroke();
}
private void line(PDPageContentStream stream, double x1Mm, double y1Mm, double x2Mm, double y2Mm)
throws IOException {
stream.setLineWidth(0.45f);
stream.moveTo(PdfUnits.mmToPt(x1Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y1Mm));
stream.lineTo(PdfUnits.mmToPt(x2Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y2Mm));
stream.stroke();
}
private String value(String text) {
return text == null ? "" : text;
}
}
@@ -0,0 +1,257 @@
package com.baiyue.print.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import jakarta.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/** Validates A4 freight documents and renders one A4 page per request document. */
@Service
public class FreightA4PdfService {
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final int MAX_FIELD_KEY_CODE_POINTS = 80;
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
private static final int MAX_CARGO_ROWS = 50;
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE);
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
@Resource private PrintPdfProperties properties;
@Resource private PdfGenerationLimiter limiter;
@Resource private FreightA4Renderer renderer;
public GeneratedPdf generate(FreightA4PdfDtos.Request request) {
NormalizedRequest normalized = validateAndNormalize(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
return render(normalized);
}
}
private GeneratedPdf render(NormalizedRequest request) {
try (PDDocument document = new PDDocument();
InputStream fontStream = openFont();
LimitedByteArrayOutputStream output =
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
PDType0Font font = PDType0Font.load(document, fontStream, true);
for (FreightA4PdfDtos.Document documentData : request.documents()) {
renderer.renderPage(document, font, request.templateCode(), documentData);
}
document.save(output);
byte[] content = output.toByteArray();
return new GeneratedPdf(content, request.fileName(), request.documents().size());
} catch (OutputLimitExceededException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "生成的 PDF 超过输出大小上限");
} catch (IOException | RuntimeException exception) {
if (exception instanceof BizException bizException) {
throw bizException;
}
throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
}
}
private InputStream openFont() throws IOException {
ClassPathResource resource = new ClassPathResource(properties.getFontResource());
if (!resource.exists()) {
throw new IOException("configured PDF font resource does not exist");
}
return resource.getInputStream();
}
private NormalizedRequest validateAndNormalize(FreightA4PdfDtos.Request request) {
if (request == null) {
invalid("request is required");
}
String templateCode = normalizeRequiredLine(request.templateCode(), "templateCode");
if (!SUPPORTED_TEMPLATES.contains(templateCode)) {
invalid("templateCode is not supported");
}
if (request.pageSpec() == null) {
invalid("pageSpec is required");
}
requireSupportedDimension(request.pageSpec().widthMm(), A4_WIDTH_MM, "pageSpec.widthMm");
requireSupportedDimension(request.pageSpec().heightMm(), A4_HEIGHT_MM, "pageSpec.heightMm");
if (request.documents() == null || request.documents().isEmpty()) {
invalid("documents must contain at least one item");
}
if (request.documents().size() > properties.getMaxPages()) {
invalid("documents must not contain more than " + properties.getMaxPages() + " items");
}
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
for (int index = 0; index < request.documents().size(); index++) {
documents.add(normalizeDocument(request.documents().get(index), index));
}
return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()),
List.copyOf(documents));
}
private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) {
if (document == null) {
invalid("documents[" + index + "] is required");
}
Map<String, String> fields = new LinkedHashMap<>();
if (document.fields() != null) {
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
invalid(field(index, "fields." + key) + " key is too long");
}
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
}
}
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
if (document.cargoRows() != null) {
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
}
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
}
}
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
}
private FreightA4PdfDtos.CargoRow normalizeCargoRow(FreightA4PdfDtos.CargoRow row, int documentIndex,
int rowIndex) {
if (row == null) {
invalid("documents[" + documentIndex + "].cargoRows[" + rowIndex + "] is required");
}
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
return new FreightA4PdfDtos.CargoRow(
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
}
private String normalizeFileName(String value) {
if (value == null) {
return "freight-a4-documents.pdf";
}
String normalized = normalizeRequiredLine(value, "downloadFileName");
if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) {
invalid("downloadFileName must not exceed 120 characters");
}
if (!normalized.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
invalid("downloadFileName must end with .pdf");
}
for (int offset = 0; offset < normalized.length(); offset++) {
if (FORBIDDEN_FILE_NAME_CHARACTERS.contains(normalized.charAt(offset))) {
invalid("downloadFileName contains a reserved character");
}
}
return normalized;
}
private String normalizeRequiredLine(String value, String field) {
String normalized = normalizeOptionalBlock(value, field);
if (normalized.isBlank()) {
invalid(field + " must not be blank");
}
if (normalized.contains("\n")) {
invalid(field + " must be a single line");
}
return normalized;
}
private String normalizeOptionalBlock(String value, String field) {
if (value == null) {
return "";
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC)
.replace("\r\n", "\n")
.replace('\r', '\n');
if (normalized.codePointCount(0, normalized.length()) > MAX_FIELD_VALUE_CODE_POINTS) {
invalid(field + " must not exceed " + MAX_FIELD_VALUE_CODE_POINTS + " characters");
}
normalized.codePoints().forEach(codePoint -> {
if (isForbiddenControl(codePoint)) {
invalid(field + " contains a control character");
}
});
return normalized.strip();
}
private boolean isForbiddenControl(int codePoint) {
int type = Character.getType(codePoint);
return codePoint != '\n' && codePoint != '\t'
&& (Character.isISOControl(codePoint)
|| type == Character.FORMAT
|| type == Character.LINE_SEPARATOR
|| type == Character.PARAGRAPH_SEPARATOR);
}
private void requireSupportedDimension(BigDecimal actual, BigDecimal expected, String field) {
if (actual == null || actual.compareTo(expected) != 0) {
invalid(field + " is not supported");
}
}
private String field(int index, String name) {
return "documents[" + index + "]." + name;
}
private void invalid(String message) {
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {
}
private record NormalizedRequest(String templateCode, String fileName, List<FreightA4PdfDtos.Document> documents) {
}
private static final class LimitedByteArrayOutputStream extends ByteArrayOutputStream {
private final long maxBytes;
private LimitedByteArrayOutputStream(long maxBytes) {
if (maxBytes < 1 || maxBytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("max-output-bytes must be between 1 and Integer.MAX_VALUE");
}
this.maxBytes = maxBytes;
}
@Override
public synchronized void write(int value) {
ensureCapacityWithinLimit(1);
super.write(value);
}
@Override
public synchronized void write(byte[] bytes, int offset, int length) {
ensureCapacityWithinLimit(length);
super.write(bytes, offset, length);
}
private void ensureCapacityWithinLimit(int addedBytes) {
if ((long) count + addedBytes > maxBytes) {
throw new OutputLimitExceededException();
}
}
}
private static final class OutputLimitExceededException extends RuntimeException {
}
}
@@ -52,6 +52,54 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
/internal/print/v1/pdfs/freight-a4-documents:
post:
summary: Generate an A4 sea-freight document PDF
operationId: generateFreightA4DocumentPdf
security:
- internalServiceToken: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FreightA4PdfRequest'
responses:
'200':
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
headers:
Content-Disposition:
description: UTF-8 attachment filename. Present only for a successful PDF.
schema:
type: string
Cache-Control:
schema:
type: string
example: no-store
X-Print-Page-Count:
schema:
type: integer
minimum: 1
maximum: 2000
X-Request-Id:
schema:
type: string
content:
application/pdf:
schema:
type: string
format: binary
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'413':
description: Request body exceeds the configured limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
components: components:
securitySchemes: securitySchemes:
internalServiceToken: internalServiceToken:
@@ -112,6 +160,66 @@ components:
minLength: 1 minLength: 1
maxLength: 34 maxLength: 34
pattern: '^[\\x20-\\x7E]+$' pattern: '^[\\x20-\\x7E]+$'
FreightA4PdfRequest:
type: object
required: [templateCode, pageSpec, documents]
properties:
templateCode:
type: string
enum: [SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1, BILL_OF_LADING_CONFIRMATION_A4_V1]
pageSpec:
$ref: '#/components/schemas/FreightA4PageSpec'
downloadFileName:
type: string
maxLength: 120
example: BYSE2607044-托运书.pdf
documents:
type: array
minItems: 1
maxItems: 2000
description: One document item renders one A4 page in request order.
items:
$ref: '#/components/schemas/FreightA4Document'
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm:
type: number
enum: [210]
heightMm:
type: number
enum: [297]
FreightA4Document:
type: object
properties:
fields:
type: object
additionalProperties:
type: string
maxLength: 4000
description: Template field map. Known keys are documented in the print module README.
cargoRows:
type: array
maxItems: 1
description: V1 templates render at most one cargo row. Callers must not send additional rows.
items:
$ref: '#/components/schemas/FreightA4CargoRow'
FreightA4CargoRow:
type: object
properties:
marksAndNos:
type: string
quantity:
type: string
packaging:
type: string
descriptionOfGoods:
type: string
grossWeight:
type: string
measurement:
type: string
ApiError: ApiError:
type: object type: object
required: [code, message] required: [code, message]
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler; import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService; import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -38,6 +40,8 @@ class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef"; private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@MockBean @MockBean
private InboundMarkPdfService service; private InboundMarkPdfService service;
@MockBean
private FreightA4PdfService freightA4PdfService;
@Resource @Resource
private InternalPdfController controller; private InternalPdfController controller;
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -127,6 +131,28 @@ class InternalPdfControllerTest {
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf"); assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
} }
@Test
void returnsFreightA4PdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-a4-test".getBytes(StandardCharsets.US_ASCII);
when(freightA4PdfService.generate(any())).thenReturn(new FreightA4PdfService.GeneratedPdf(
pdf, "BYSE2607044-托运书.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validFreightA4RequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("X-Print-Page-Count", "1"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
}
@Test @Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception { void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class PostMapping mapping = InternalPdfController.class
@@ -142,6 +168,7 @@ class InternalPdfControllerTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(contract) assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:") .contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
.contains("application/pdf:") .contains("application/pdf:")
.contains("application/json:") .contains("application/json:")
.contains("name: X-Internal-Service-Token") .contains("name: X-Internal-Service-Token")
@@ -165,4 +192,29 @@ class InternalPdfControllerTest {
"""; """;
} }
private String validFreightA4RequestJson() {
return """
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {"widthMm": 210, "heightMm": 297},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
"jobNumber": "BYSE2607044"
},
"cargoRows": [{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}]
}]
}
""";
}
} }
@@ -39,6 +39,19 @@ class PrintRequestSizeFilterTest {
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
} }
@Test
void alsoCapsFreightA4DocumentRequests() throws Exception {
MockHttpServletRequest request = request("12345678901", true,
"/internal/print/v1/pdfs/freight-a4-documents");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
@Test @Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception { void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() { MockHttpServletRequest request = new MockHttpServletRequest() {
@@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest {
} }
private MockHttpServletRequest request(String content, boolean unknownLength) { private MockHttpServletRequest request(String content, boolean unknownLength) {
return request(content, unknownLength, "/internal/print/v1/pdfs/inbound-marks");
}
private MockHttpServletRequest request(String content, boolean unknownLength, String path) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() { MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; } @Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; } @Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest(); } : new MockHttpServletRequest();
initialize(request, content); initialize(request, content, path);
return request; return request;
} }
private void initialize(MockHttpServletRequest request, String content) { private void initialize(MockHttpServletRequest request, String content) {
initialize(request, content, "/internal/print/v1/pdfs/inbound-marks");
}
private void initialize(MockHttpServletRequest request, String content, String path) {
request.setMethod("POST"); request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks"); request.setRequestURI(path);
request.setContent(content.getBytes(StandardCharsets.UTF_8)); request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123"); request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123"); request.setAttribute("traceId", "trace-123");
@@ -0,0 +1,134 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import com.baiyue.print.renderer.PdfUnits;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class,
PdfGenerationLimiter.class, FreightA4Renderer.class})
class FreightA4PdfServiceGenerationTest {
@Resource
private FreightA4PdfService service;
@Test
void generatesShippingInstructionAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
"BYSE2607044-shipping-instruction.pdf",
shippingDocument()));
assertThat(generated.fileName()).isEqualTo("BYSE2607044-shipping-instruction.pdf");
assertThat(generated.pageCount()).isEqualTo(1);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr",
"BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.");
}
}
@Test
void generatesBillConfirmationAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE,
"BYSE2607044-bill-confirmation.pdf",
billConfirmationDocument()));
assertThat(generated.pageCount()).isEqualTo(1);
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("WHY074G504054", "D R A F T", "SAID TO CONTAIN(S)",
"WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM");
}
}
private FreightA4PdfDtos.Request request(String templateCode, String fileName,
FreightA4PdfDtos.Document document) {
return new FreightA4PdfDtos.Request(templateCode,
new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297.0")),
fileName, List.of(document));
}
private FreightA4PdfDtos.Document shippingDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("toCompany", "宁波胜泽远航国际物流有限公司"),
Map.entry("issuerNameCn", "浙江柏悦贸易有限公司(香港)"),
Map.entry("issuerNameEn", "ZHEJIANG BAIYUE TRADE CO.,LIMITED"),
Map.entry("issuerAddress", "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室"),
Map.entry("issuerPostalCode", "322000"),
Map.entry("issuerTel", "0579-85875557"),
Map.entry("issuerFax", "0579-85601277"),
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("picName", "苏天翔"),
Map.entry("jobNumber", "BYSE2607044"),
Map.entry("etdDate", "2026/07/24"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("cargoQuantity", "1*20GP"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("totalPackagesInWords", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."),
Map.entry("carrier", "万海航运(香港)股份有限公司"),
Map.entry("bookingAgent", "宁波胜泽远航国际物流有限公司"),
Map.entry("printDate", "2026 年 07 月 16 日")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "700", "PACKAGES",
"CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private FreightA4PdfDtos.Document billConfirmationDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN\nSTREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("bookingNumber", "WHY074G504054"),
Map.entry("ensAmsCutoffDate", "2026/07/20"),
Map.entry("expectedDepartureDate", "2026/07/24"),
Map.entry("oceanVesselVoyage", "WAN HAI 622 W027"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("finalDestination", "EL DEKHEILA"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("shipperLoadCountSeal", "SHPR'S LOAD,COUNT AND SEAL"),
Map.entry("containerSpec", "1*20GP"),
Map.entry("shippedOnBoard", "24-JUL-2026"),
Map.entry("draftText", "D R A F T"),
Map.entry("totalPackagesInWords", "SAY ONE THOUSAND AND TWENTY EIGHT PACKAGES ONLY."),
Map.entry("containerSummary", "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"),
Map.entry("notes", "备注:1.多个箱子请分别提供分箱数,分箱品名,若未指出默认品名混装.\n2.请在 ENS/AMS 规定时间内确认好提单内容,否则有上了不船的风险."),
Map.entry("issuePlace", "NINGBO"),
Map.entry("issueDate", "24-JUL-2026")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "1028", "PACKAGES",
"AUTO SPARE PARTS\nHS CODE 870899\nACID: 4120661061019510011\nEGYPTIAN IMPORTER VAT NUMBER:412066106\nEXPORTER REGISTRATION\nNUMBER:CN-02-91330782MA2E62HK3Y\n申请目的港 14 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private void assertA4(PDDocument document) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
assertThat(document.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getRotation()).isZero();
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class})
class FreightA4PdfServiceValidationTest {
@MockBean private PdfGenerationLimiter limiter;
@MockBean private FreightA4Renderer renderer;
@jakarta.annotation.Resource private FreightA4PdfService service;
@Test
void rejectsUnsupportedTemplateCode() {
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
pageSpec(), "a4.pdf", List.of(document())), "templateCode");
}
@Test
void rejectsNonA4PageSize() {
assertValidationFailure(new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
"a4.pdf", List.of(document())), "pageSpec.widthMm");
}
@Test
void allowsMultilineDocumentFields() {
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
pageSpec(), "a4.pdf", List.of(document()));
assertThatCode(() -> service.generate(request)).doesNotThrowAnyException();
}
private void assertValidationFailure(FreightA4PdfDtos.Request request, String messagePart) {
assertThatThrownBy(() -> service.generate(request))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(exception.getMessage()).contains(messagePart);
});
}
private FreightA4PdfDtos.PageSpec pageSpec() {
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
}
private FreightA4PdfDtos.Document document() {
return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"),
List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")));
}
}
@@ -9,27 +9,26 @@ import com.baiyue.warehouse.service.PartnerQueryService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/api/merchant/v1/partners") @RequestMapping("/api/merchant/v1/partners")
public class MerchantPartnerController { public class MerchantPartnerController {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource @Resource
private PartnerQueryService service; private PartnerQueryService service;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@GetMapping("/candidates") @GetMapping("/candidates")
public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String keyword,@RequestParam(required=false) String type) { @RequestParam(required=false) String keyword,@RequestParam(required=false) String type) {
requireMerchant(user); requireMerchant(user);
return ApiResponse.success(service.candidates(publicTenantId,keyword,type)); return ApiResponse.success(service.candidates(PUBLIC_TENANT_ID,keyword,type));
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public ApiResponse<PartnerEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { public ApiResponse<PartnerEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
requireMerchant(user); requireMerchant(user);
return ApiResponse.success(service.active(publicTenantId,id)); return ApiResponse.success(service.active(PUBLIC_TENANT_ID,id));
} }
private static void requireMerchant(AuthenticatedUser user) { private static void requireMerchant(AuthenticatedUser user) {
@@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -23,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
* 商户端公共仓库查询接口。认证用户共享平台默认租户维护的仓库配置。 * 商户端公共仓库查询接口。认证用户共享平台公共作用域维护的仓库配置。
*/ */
@Tag( @Tag(
name = "Merchant warehouses / 商户仓库", name = "Merchant warehouses / 商户仓库",
@@ -32,15 +31,14 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/api/merchant/v1/warehouses") @RequestMapping("/api/merchant/v1/warehouses")
public class MerchantWarehouseController { public class MerchantWarehouseController {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource @Resource
private WarehouseApplicationService service; private WarehouseApplicationService service;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId;
@Operation( @Operation(
summary = "List available warehouses / 查询可用仓库", summary = "List available warehouses / 查询可用仓库",
description = "Returns active public warehouses maintained under the configured public tenant. / 返回平台默认租户维护的可用公共仓库。" description = "Returns active public warehouses maintained under the platform public scope. / 返回平台公共作用域维护的可用公共仓库。"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -51,12 +49,12 @@ public class MerchantWarehouseController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseRequests.QueryRequest query) { @Valid WarehouseRequests.QueryRequest query) {
requireMerchant(user); requireMerchant(user);
return ApiResponse.success(service.merchantPage(publicTenantId, query)); return ApiResponse.success(service.merchantPage(PUBLIC_TENANT_ID, query));
} }
@Operation( @Operation(
summary = "Get warehouse details / 查询仓库详情", summary = "Get warehouse details / 查询仓库详情",
description = "Returns a public warehouse maintained under the configured public tenant. / 返回平台默认租户维护的公共仓库详情。" description = "Returns a public warehouse maintained under the platform public scope. / 返回平台公共作用域维护的公共仓库详情。"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -68,7 +66,7 @@ public class MerchantWarehouseController {
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id) { @PathVariable long id) {
requireMerchant(user); requireMerchant(user);
return ApiResponse.success(service.activeDetail(publicTenantId, id)); return ApiResponse.success(service.activeDetail(PUBLIC_TENANT_ID, id));
} }
private static void requireMerchant(AuthenticatedUser user) { private static void requireMerchant(AuthenticatedUser user) {
@@ -36,11 +36,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/** 平台库位管理接口;分页支持跨企业查询,写操作仍以目标 tenantId 隔离。 */ /** 平台库位管理接口;统一维护平台公共作用域下的库位资源。 */
@Validated @Validated
@RestController @RestController
@RequestMapping("/api/platform/v1/locations") @RequestMapping("/api/platform/v1/locations")
@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料") @Tag(name = "平台库位管理", description = "按平台公共作用域和仓库管理库位基础资料")
public class PlatformLocationController { public class PlatformLocationController {
@Resource @Resource
private LocationApplicationService service; private LocationApplicationService service;
@@ -48,14 +48,14 @@ public class PlatformLocationController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:location:list')") @PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "分页查询库位", @Operation(summary = "分页查询库位",
description = "默认分页查询全部企业库位;可按目标企业租户、仓库、库区、编码、类型和状态筛选") description = "默认分页查询全部平台公共库位;可按仓库、库区、编码、类型和状态筛选")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于目标企业租户") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于平台公共作用域")
}) })
public ApiResponse<PageResult<LocationEntity>> page( public ApiResponse<PageResult<LocationEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业") @Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
@RequestParam(required = false) @Min(0) Long tenantId, @RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PageQuery query) { @Valid @ParameterObject PageQuery query) {
requirePlatform(user); requirePlatform(user);
@@ -65,7 +65,7 @@ public class PlatformLocationController {
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:location:list')") @PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属") @Operation(summary = "查询库位详情", description = "详情查询同时校验平台公共作用域和仓库归属")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"),
@@ -98,7 +98,7 @@ public class PlatformLocationController {
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('platform:location:create')") @PreAuthorize("hasAuthority('platform:location:create')")
@Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位") @Operation(summary = "新增库位", description = "在平台公共仓库内新增唯一编码库位")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"),
@@ -32,17 +32,17 @@ import org.springframework.web.bind.annotation.RestController;
@Validated @Validated
@RestController @RestController
@RequestMapping("/api/platform/v1/pallets") @RequestMapping("/api/platform/v1/pallets")
@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据") @Tag(name = "平台托盘管理", description = "按平台公共作用域和仓库维护托盘主数据")
public class PlatformPalletController { public class PlatformPalletController {
@Resource @Resource
private PalletApplicationService service; private PalletApplicationService service;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:pallet:list')") @PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "分页查询托盘", description = "默认查询全部企业;可按企业、仓库、托盘编码和类型筛选") @Operation(summary = "分页查询托盘", description = "默认查询全部平台公共托盘;可按仓库、托盘编码和类型筛选")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限")) @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限"))
public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标企业租户ID;不传时查询全部企业") @Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
@RequestParam(required = false) @Min(0) Long tenantId, @RequestParam(required = false) @Min(0) Long tenantId,
@Valid @ParameterObject PalletRequests.PageQuery query) { @Valid @ParameterObject PalletRequests.PageQuery query) {
requirePlatform(user); requirePlatform(user);
@@ -6,8 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseDetailResponse;
import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult;
import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
import com.baiyue.warehouse.service.WarehouseImportService;
import com.baiyue.warehouse.service.WarehouseApplicationService; import com.baiyue.warehouse.service.WarehouseApplicationService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -16,6 +19,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -27,25 +35,32 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** /**
* 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 * 平台端仓库管理接口,统一维护平台公共作用域下的仓库主数据。
*/ */
@Validated @Validated
@Tag( @Tag(
name = "Platform warehouse administration / 平台仓库管理", name = "Platform warehouse administration / 平台仓库管理",
description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" description = "Platform APIs for managing public warehouses. / 平台端管理公共仓库的接口。"
) )
@RestController @RestController
@RequestMapping("/api/platform/v1/warehouse-admin") @RequestMapping("/api/platform/v1/warehouse-admin")
public class PlatformWarehouseController { public class PlatformWarehouseController {
@Resource @Resource
private WarehouseApplicationService service; private WarehouseApplicationService service;
@Resource
private WarehouseImportService importService;
@Operation( @Operation(
summary = "Create warehouse / 新建仓库", summary = "Create warehouse / 新建仓库",
description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" description = "Creates a public warehouse, address and service capabilities. / 创建平台公共仓库、地址和服务能力。"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"),
@@ -58,7 +73,7 @@ public class PlatformWarehouseController {
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseRequests.SaveRequest request) { @Valid @RequestBody WarehouseRequests.SaveRequest request) {
requirePlatform(user); requirePlatform(user);
requireTenant(request.tenantId()); if (request.tenantId() != null) requireTenant(request.tenantId());
return ApiResponse.success(service.create(request)); return ApiResponse.success(service.create(request));
} }
@@ -69,7 +84,7 @@ public class PlatformWarehouseController {
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or tenant validation failed / 需要平台仓库编辑权限,或租户校验失败"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse edit permission required (platform:warehouse:edit), or scope validation failed / 需要平台仓库编辑权限,或作用域校验失败"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
}) })
@@ -77,7 +92,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:edit')") @PreAuthorize("hasAuthority('platform:warehouse:edit')")
public ApiResponse<Void> update( public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId, @RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id, @PathVariable long id,
@@ -90,7 +105,7 @@ public class PlatformWarehouseController {
@Operation( @Operation(
summary = "List platform warehouses / 查询平台仓库", summary = "List platform warehouses / 查询平台仓库",
description = "Returns all platform warehouses by default; customer ID is an optional compatibility filter. / 默认分页查询平台全部仓库,客户 ID 仅作为兼容筛选条件。" description = "Returns public platform warehouses; tenantId is kept only for compatibility and must be 0. / 分页查询平台公共仓库;tenantId 仅保留兼容且必须为 0。"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -99,19 +114,64 @@ public class PlatformWarehouseController {
}) })
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:warehouse:list')") @PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<PageResult<WarehouseEntity>> page( public ApiResponse<PageResult<WarehouseSummaryResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Optional customer ID / 可选客户 ID", example = "10001") @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0")
@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long tenantId,
@Valid WarehouseRequests.QueryRequest request) { @Valid @ParameterObject WarehouseRequests.QueryRequest request) {
requirePlatform(user); requirePlatform(user);
if (tenantId != null) requireTenant(tenantId); if (tenantId != null) requireTenant(tenantId);
return ApiResponse.success(service.page(0L, request)); return ApiResponse.success(service.pageSummaries(0L, request));
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:warehouse:create')")
@Operation(summary = "Download warehouse import template / 下载仓库导入模板",
description = "Downloads the current XLSX template for warehouse and FBA warehouse import. / 下载仓库与 FBA 仓库导入的当前 XLSX 模板。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Template downloaded / 模板下载成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required (platform:warehouse:create) / 需要平台仓库新增权限")
})
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("warehouse-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:warehouse:create')")
@Operation(summary = "Import warehouses / 批量导入仓库",
description = "Validates the whole XLSX file and atomically creates or optionally updates warehouses. Default mode is CREATE_ONLY. / 整表校验 XLSX 后原子新增或可选更新仓库,默认 CREATE_ONLY。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Import precheck completed / 导入预检完成"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or query / 文件或参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform warehouse create permission required; UPSERT also requires edit permission (platform:warehouse:create, platform:warehouse:edit) / 需要平台仓库新增权限;UPSERT 还需要编辑权限")
})
public ApiResponse<WarehouseImportResult> importWarehouses(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0")
@RequestParam(required = false) Long tenantId,
@Parameter(description = "Import mode, defaults to CREATE_ONLY / 导入模式,默认 CREATE_ONLY")
@RequestParam(required = false) ImportMode mode,
@Parameter(description = "Whether to only precheck without writing data / 是否只预检不写入", example = "true")
@RequestParam(defaultValue = "false") boolean dryRun,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
requireImportPermission(user, mode);
if (tenantId != null) requireTenant(tenantId);
try {
return ApiResponse.success(importService.importFile(0L, file.getOriginalFilename(), file.getBytes(), mode, dryRun));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
} }
@Operation( @Operation(
summary = "Get tenant warehouse details / 查询租户仓库详情", summary = "Get public warehouse details / 查询公共仓库详情",
description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" description = "Returns the public warehouse, addresses and service capabilities. / 返回平台公共仓库、地址及服务能力。"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -122,7 +182,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:list')") @PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<WarehouseDetailResponse> detail( public ApiResponse<WarehouseDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId, @RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id) { @PathVariable long id) {
@@ -145,7 +205,7 @@ public class PlatformWarehouseController {
@PreAuthorize("hasAuthority('platform:warehouse:status')") @PreAuthorize("hasAuthority('platform:warehouse:status')")
public ApiResponse<Void> status( public ApiResponse<Void> status(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @Parameter(description = "Compatibility scope; only 0 is allowed / 兼容作用域,仅允许 0", example = "0", required = true)
@RequestParam @NotNull Long tenantId, @RequestParam @NotNull Long tenantId,
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
@PathVariable long id, @PathVariable long id,
@@ -169,4 +229,11 @@ public class PlatformWarehouseController {
throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0"); throw new BizException(ResultCode.BAD_REQUEST, "Platform resource scope must be 0 / 平台公共资源作用域必须为0");
} }
} }
private static void requireImportPermission(AuthenticatedUser user, ImportMode mode) {
if (mode == ImportMode.UPSERT && !user.permissions().contains("platform:warehouse:edit")) {
throw new BizException(ResultCode.FORBIDDEN,
"UPSERT import requires platform:warehouse:edit / 新增并更新导入需要平台仓库编辑权限");
}
}
} }
@@ -0,0 +1,51 @@
package com.baiyue.warehouse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** 仓库与 FBA 仓库 XLSX 导入契约。 */
public final class WarehouseImportDtos {
private WarehouseImportDtos() {
}
@Schema(name = "WarehouseImportMode", description = "Warehouse import mode / 仓库导入模式")
public enum ImportMode {
@Schema(description = "Only create new rows; existing rows are reported but not modified / 仅新增,现有数据只预检分类不修改")
CREATE_ONLY,
@Schema(description = "Create new rows and update existing rows without overriding contact or remark / 新增并更新,更新不覆盖联系人和备注")
UPSERT
}
@Schema(name = "WarehouseImportError", description = "Warehouse import row error / 仓库导入行错误")
public record WarehouseImportError(
@Schema(description = "Excel row number / Excel 行号") int row,
@Schema(description = "Field name / 字段") String field,
@Schema(description = "Error message / 错误原因") String message
) {
}
@Schema(name = "WarehouseImportPreviewRow", description = "Warehouse import preview row / 仓库导入预检行")
public record WarehouseImportPreviewRow(
@Schema(description = "Excel row number / Excel 行号") int row,
@Schema(description = "Warehouse code / 仓库编码") String warehouseCode,
@Schema(description = "Resolved action / 预检动作", allowableValues = {"CREATE", "UPDATE", "UNCHANGED", "ERROR"}) String action,
@Schema(description = "Reason / 原因") String reason
) {
}
@Schema(name = "WarehouseImportResult", description = "Warehouse import result / 仓库导入结果")
public record WarehouseImportResult(
@Schema(description = "Original file name / 原始文件名") String fileName,
@Schema(description = "Import mode / 导入模式") ImportMode mode,
@Schema(description = "Data row count / 数据行总数") int totalRows,
@Schema(description = "Created row count / 新增行数") int createdRows,
@Schema(description = "Updated row count / 更新行数") int updatedRows,
@Schema(description = "Unchanged row count / 无变化行数") int unchangedRows,
@Schema(description = "Whether the import passed validation and committed writable rows / 是否校验通过并写入可写行") boolean success,
@Schema(description = "Whether writable rows have been committed / 是否已经写入数据") boolean committed,
@Schema(description = "Per-row preview classifications / 逐行预检分类") List<WarehouseImportPreviewRow> previewRows,
@Schema(description = "Per-row errors / 逐行错误") List<WarehouseImportError> errors
) {
}
}
@@ -5,10 +5,10 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotBlank;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalTime; import java.time.LocalTime;
@@ -23,26 +23,26 @@ public final class WarehouseRequests {
@Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求")
public record SaveRequest( public record SaveRequest(
@Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Compatibility tenant ID; platform APIs force it to 0 / 兼容租户字段,平台接口固定为 0", example = "0")
@NotNull Long tenantId, Long tenantId,
@Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 50) String warehouseCode, @NotBlank @Size(max = 50) String warehouseCode,
@Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 120) String warehouseName, @NotBlank @Size(max = 120) String warehouseName,
@Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC")
@NotBlank @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
@Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}")
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, @Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8")
@NotBlank String timezone, String timezone,
@Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64)
@Size(max = 64) String contactName, @Size(max = 64) String contactName,
@Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32)
@Size(max = 32) String contactPhone, @Size(max = 32) String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com")
@Email String contactEmail, @Email String contactEmail,
@Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Warehouse status; defaults to ACTIVE / 仓库状态,默认 ACTIVE", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, defaultValue = "ACTIVE")
@NotBlank String status, @Pattern(regexp = "ACTIVE|INACTIVE") String status,
@Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500)
@Size(max = 500) String remark, @Size(max = 500) String remark,
@Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -54,10 +54,10 @@ public final class WarehouseRequests {
@Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址")
public record AddressRequest( public record AddressRequest(
@Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Address type; defaults to WAREHOUSE / 地址类型,默认 WAREHOUSE", example = "WAREHOUSE")
@NotBlank String addressType, String addressType,
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "ISO two-letter country code; defaults from warehouse country / ISO 两位国家代码,默认跟随仓库国家", example = "US")
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, @Pattern(regexp = "[A-Z]{2}") String countryCode,
@Schema(description = "State or province / 州或省", example = "California", maxLength = 100) @Schema(description = "State or province / 州或省", example = "California", maxLength = 100)
@Size(max = 100) String stateProvince, @Size(max = 100) String stateProvince,
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -98,6 +98,8 @@ public final class WarehouseRequests {
@Min(1) long page, @Min(1) long page,
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
@Min(1) @Max(200) long size, @Min(1) @Max(200) long size,
@Schema(description = "Keyword across code, name, primary city, state and address / 关键字,覆盖编码、名称、主地址城市、州和地址", example = "Los Angeles")
@Size(max = 128) String keyword,
@Schema(description = "Country filter / 国家筛选", example = "US") @Schema(description = "Country filter / 国家筛选", example = "US")
String countryCode, String countryCode,
@Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE")
@@ -105,7 +107,7 @@ public final class WarehouseRequests {
@Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"})
@Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
@Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
String status @Pattern(regexp = "ACTIVE|INACTIVE") String status
) { ) {
public QueryRequest { public QueryRequest {
if (page == 0) { if (page == 0) {
@@ -0,0 +1,65 @@
package com.baiyue.warehouse.dto;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/** 仓库列表响应,带主地址摘要,避免列表页逐条请求详情。 */
@Schema(name = "WarehouseSummaryResponse", description = "Warehouse list item with primary address summary / 仓库列表项及主地址摘要")
public record WarehouseSummaryResponse(
@Schema(description = "Warehouse ID / 仓库 ID") Long id,
@Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId,
@Schema(description = "Warehouse code / 仓库编码") String warehouseCode,
@Schema(description = "Warehouse name / 仓库名称") String warehouseName,
@Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) String warehouseType,
@Schema(description = "ISO two-letter country code / 国家二字码") String countryCode,
@Schema(description = "UTC offset / UTC 偏移量") String timezone,
@Schema(description = "Contact name / 联系人") String contactName,
@Schema(description = "Contact phone / 联系电话") String contactPhone,
@Schema(description = "Contact email / 联系邮箱") String contactEmail,
@Schema(description = "Warehouse status / 仓库状态", allowableValues = {"ACTIVE", "INACTIVE"}) String status,
@Schema(description = "Remark / 备注") String remark,
@Schema(description = "Creation time / 创建时间") LocalDateTime createdAt,
@Schema(description = "Last update time / 更新时间") LocalDateTime updatedAt,
@Schema(description = "Primary address summary / 主地址摘要") AddressSummary primaryAddress
) {
public static WarehouseSummaryResponse of(WarehouseEntity warehouse, WarehouseAddressEntity address) {
return new WarehouseSummaryResponse(
warehouse.getId(), warehouse.getTenantId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName(),
warehouse.getWarehouseType(), warehouse.getCountryCode(), warehouse.getTimezone(),
warehouse.getContactName(), warehouse.getContactPhone(), warehouse.getContactEmail(),
warehouse.getStatus(), warehouse.getRemark(), warehouse.getCreatedAt(), warehouse.getUpdatedAt(),
address == null ? null : AddressSummary.of(address)
);
}
@Schema(name = "WarehouseAddressSummary", description = "Primary warehouse address summary / 仓库主地址摘要")
public record AddressSummary(
@Schema(description = "Address ID / 地址 ID") Long id,
@Schema(description = "Address type / 地址类型") String addressType,
@Schema(description = "Country code / 国家二字码") String countryCode,
@Schema(description = "State or province / 州或省") String stateProvince,
@Schema(description = "City / 城市") String city,
@Schema(description = "District / 区县") String district,
@Schema(description = "Postal code / 邮政编码") String postalCode,
@Schema(description = "Address line 1 / 地址第一行") String addressLine1,
@Schema(description = "Address line 2 / 地址第二行") String addressLine2,
@Schema(description = "Display address / 展示地址") String displayAddress
) {
static AddressSummary of(WarehouseAddressEntity address) {
return new AddressSummary(address.getId(), address.getAddressType(), address.getCountryCode(),
address.getStateProvince(), address.getCity(), address.getDistrict(), address.getPostalCode(),
address.getAddressLine1(), address.getAddressLine2(), displayAddress(address));
}
private static String displayAddress(WarehouseAddressEntity address) {
return java.util.stream.Stream.of(address.getStateProvince(), address.getCity(), address.getDistrict(),
address.getAddressLine1(), address.getAddressLine2())
.filter(value -> value != null && !value.isBlank())
.reduce((left, right) -> left + " " + right)
.orElse("");
}
}
}
@@ -31,7 +31,7 @@ public class WarehouseEntity extends BaseEntity {
private String warehouseType; private String warehouseType;
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US")
private String countryCode; private String countryCode;
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles") @Schema(description = "UTC offset / UTC 偏移量", example = "UTC-8")
private String timezone; private String timezone;
@Schema(description = "Contact name / 联系人", example = "Alex Chen") @Schema(description = "Contact name / 联系人", example = "Alex Chen")
private String contactName; private String contactName;
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseEntity;
@@ -14,7 +15,10 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。
@@ -42,6 +46,12 @@ public class WarehouseRepository {
.eq(WarehouseEntity::getId, id)); .eq(WarehouseEntity::getId, id));
} }
public WarehouseEntity findByCode(long tenantId, String code) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
.eq(WarehouseEntity::getWarehouseCode, code));
}
public WarehouseEntity findActiveOwned(long tenantId, long id) { public WarehouseEntity findActiveOwned(long tenantId, long id) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>() return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId) .eq(WarehouseEntity::getTenantId, tenantId)
@@ -57,11 +67,21 @@ public class WarehouseRepository {
} }
public PageResult<WarehouseEntity> page(Long tenantId, WarehouseRequests.QueryRequest query) { public PageResult<WarehouseEntity> page(Long tenantId, WarehouseRequests.QueryRequest query) {
String keyword = StringUtils.hasText(query.keyword()) ? query.keyword().trim() : null;
List<Long> addressMatches = StringUtils.hasText(keyword) ? addressWarehouseIdsByKeyword(tenantId, keyword) : List.of();
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>() LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
.eq(tenantId != null, WarehouseEntity::getTenantId, tenantId) .eq(tenantId != null, WarehouseEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.warehouseType()), WarehouseEntity::getWarehouseType, query.warehouseType()) .eq(StringUtils.hasText(query.warehouseType()), WarehouseEntity::getWarehouseType, query.warehouseType())
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
.and(StringUtils.hasText(keyword), condition -> {
condition.like(WarehouseEntity::getWarehouseCode, keyword)
.or()
.like(WarehouseEntity::getWarehouseName, keyword);
if (!addressMatches.isEmpty()) {
condition.or().in(WarehouseEntity::getId, addressMatches);
}
})
.orderByDesc(WarehouseEntity::getCreatedAt); .orderByDesc(WarehouseEntity::getCreatedAt);
if (StringUtils.hasText(query.capabilityCode())) { if (StringUtils.hasText(query.capabilityCode())) {
@@ -82,8 +102,19 @@ public class WarehouseRepository {
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
} }
public PageResult<WarehouseSummaryResponse> pageSummaries(Long tenantId, WarehouseRequests.QueryRequest query) {
PageResult<WarehouseEntity> page = page(tenantId, query);
Map<Long, WarehouseAddressEntity> primaryAddresses = primaryAddresses(tenantId,
page.records().stream().map(WarehouseEntity::getId).toList());
return new PageResult<>(
page.records().stream()
.map(warehouse -> WarehouseSummaryResponse.of(warehouse, primaryAddresses.get(warehouse.getId())))
.toList(),
page.page(), page.size(), page.total(), page.pages());
}
public PageResult<WarehouseEntity> activePage(long tenantId, WarehouseRequests.QueryRequest query) { public PageResult<WarehouseEntity> activePage(long tenantId, WarehouseRequests.QueryRequest query) {
return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), return page(tenantId, new WarehouseRequests.QueryRequest(query.page(), query.size(), query.keyword(),
query.countryCode(), query.capabilityCode(), query.warehouseType(), "ACTIVE")); query.countryCode(), query.capabilityCode(), query.warehouseType(), "ACTIVE"));
} }
@@ -104,6 +135,11 @@ public class WarehouseRepository {
capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId); capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId);
} }
public void replaceAddress(long tenantId, long warehouseId, WarehouseAddressEntity address) {
addresses.physicallyDeleteByWarehouse(tenantId, warehouseId);
addresses.insert(address);
}
public void insertAddress(WarehouseAddressEntity address) { public void insertAddress(WarehouseAddressEntity address) {
addresses.insert(address); addresses.insert(address);
} }
@@ -111,4 +147,40 @@ public class WarehouseRepository {
public void insertCapability(ServiceCapabilityEntity capability) { public void insertCapability(ServiceCapabilityEntity capability) {
capabilities.insert(capability); capabilities.insert(capability);
} }
private List<Long> addressWarehouseIdsByKeyword(Long tenantId, String keyword) {
return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.select(WarehouseAddressEntity::getWarehouseId)
.eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId)
.and(condition -> condition
.like(WarehouseAddressEntity::getStateProvince, keyword)
.or()
.like(WarehouseAddressEntity::getCity, keyword)
.or()
.like(WarehouseAddressEntity::getAddressLine1, keyword)
.or()
.like(WarehouseAddressEntity::getAddressLine2, keyword))
.orderByAsc(WarehouseAddressEntity::getWarehouseId))
.stream()
.map(WarehouseAddressEntity::getWarehouseId)
.distinct()
.toList();
}
private Map<Long, WarehouseAddressEntity> primaryAddresses(Long tenantId, Collection<Long> warehouseIds) {
if (warehouseIds == null || warehouseIds.isEmpty()) {
return Map.of();
}
List<WarehouseAddressEntity> records = addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
.eq(tenantId != null, WarehouseAddressEntity::getTenantId, tenantId)
.in(WarehouseAddressEntity::getWarehouseId, warehouseIds)
.orderByAsc(WarehouseAddressEntity::getWarehouseId)
.orderByDesc(WarehouseAddressEntity::getAddressType)
.orderByAsc(WarehouseAddressEntity::getId));
Map<Long, WarehouseAddressEntity> grouped = new LinkedHashMap<>();
for (WarehouseAddressEntity address : records) {
grouped.putIfAbsent(address.getWarehouseId(), address);
}
return grouped;
}
} }
@@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseDetailResponse;
import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseEntity;
@@ -34,10 +35,12 @@ public class WarehouseApplicationService {
captureBefore = false captureBefore = false
) )
public Long create(WarehouseRequests.SaveRequest request) { public Long create(WarehouseRequests.SaveRequest request) {
ensureCodeUnique(request.tenantId(), request.warehouseCode(), null); WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize(
WarehouseEntity warehouse = toEntity(request); request, WarehouseSaveNormalizer.PUBLIC_TENANT_ID);
ensureCodeUnique(normalized.tenantId(), normalized.warehouseCode(), null);
WarehouseEntity warehouse = toEntity(normalized);
repository.insertWarehouse(warehouse); repository.insertWarehouse(warehouse);
insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities()); insertChildren(warehouse.getTenantId(), warehouse.getId(), normalized.address(), normalized.capabilities());
return warehouse.getId(); return warehouse.getId();
} }
@@ -49,24 +52,29 @@ public class WarehouseApplicationService {
description = "Update warehouse / 更新仓库" description = "Update warehouse / 更新仓库"
) )
public void update(long tenantId, long id, WarehouseRequests.SaveRequest request) { public void update(long tenantId, long id, WarehouseRequests.SaveRequest request) {
if (request.tenantId() == null || tenantId != request.tenantId()) { if (request.tenantId() != null && tenantId != request.tenantId()) {
throw new BizException(ResultCode.FORBIDDEN, "Tenant mismatch / 租户不匹配"); throw new BizException(ResultCode.FORBIDDEN, "Tenant mismatch / 租户不匹配");
} }
WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize(request, tenantId);
WarehouseEntity old = owned(tenantId, id); WarehouseEntity old = owned(tenantId, id);
ensureCodeUnique(tenantId, request.warehouseCode(), id); ensureCodeUnique(tenantId, normalized.warehouseCode(), id);
WarehouseEntity warehouse = toEntity(request); WarehouseEntity warehouse = toEntity(normalized);
warehouse.setId(old.getId()); warehouse.setId(old.getId());
repository.saveWarehouse(warehouse); repository.saveWarehouse(warehouse);
repository.physicallyDeleteChildren(tenantId, id); repository.physicallyDeleteChildren(tenantId, id);
insertChildren(tenantId, id, request.address(), request.capabilities()); insertChildren(tenantId, id, normalized.address(), normalized.capabilities());
} }
public PageResult<WarehouseEntity> page(Long tenantId, WarehouseRequests.QueryRequest query) { public PageResult<WarehouseEntity> page(Long tenantId, WarehouseRequests.QueryRequest query) {
return repository.page(tenantId, query); return repository.page(tenantId, query);
} }
public PageResult<WarehouseSummaryResponse> pageSummaries(Long tenantId, WarehouseRequests.QueryRequest query) {
return repository.pageSummaries(tenantId, query);
}
public WarehouseDetailResponse detail(long tenantId, long id) { public WarehouseDetailResponse detail(long tenantId, long id) {
WarehouseEntity warehouse = owned(tenantId, id); WarehouseEntity warehouse = owned(tenantId, id);
List<WarehouseAddressEntity> addresses = repository.findAddresses(tenantId, id); List<WarehouseAddressEntity> addresses = repository.findAddresses(tenantId, id);
@@ -120,7 +128,7 @@ public class WarehouseApplicationService {
} }
} }
private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { private WarehouseEntity toEntity(WarehouseSaveNormalizer.NormalizedSaveRequest request) {
WarehouseEntity warehouse = new WarehouseEntity(); WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setTenantId(request.tenantId()); warehouse.setTenantId(request.tenantId());
warehouse.setWarehouseCode(request.warehouseCode()); warehouse.setWarehouseCode(request.warehouseCode());
@@ -169,4 +177,5 @@ public class WarehouseApplicationService {
repository.insertCapability(entity); repository.insertCapability(entity);
} }
} }
} }
@@ -0,0 +1,342 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportError;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportPreviewRow;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.util.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
/** 仓库与 FBA 仓库标准模板生成、整表预检和原子导入。 */
@Service
public class WarehouseImportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final String[] HEADERS = {
"仓库编码*", "仓库名称", "仓库类型", "国家二字码", "省/州", "城市*", "区/县",
"详细地址*", "邮政编码", "时区", "状态", "联系人", "联系电话", "联系邮箱", "备注"
};
@Resource
private WarehouseRepository repository;
@Resource
private WarehouseApplicationService warehouseService;
public byte[] template() {
try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("仓库");
Row version = sheet.createRow(0);
version.createCell(0).setCellValue("模板版本");
version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1);
instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue(
"默认仅新增;仓库类型默认 DOMESTIC,状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) {
header.createCell(i).setCellValue(HEADERS[i]);
sheet.setColumnWidth(i, Math.min(48, Math.max(14, HEADERS[i].length() * 2 + 4)) * 256);
}
workbook.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("生成仓库导入模板失败", exception);
}
}
@Transactional
@BusinessAudit(event = "WAREHOUSE_IMPORT", resourceType = "WAREHOUSE_IMPORT", description = "批量导入仓库", captureBefore = false)
public WarehouseImportResult importFile(long tenantId, String fileName, byte[] content, ImportMode mode) {
return importFile(tenantId, fileName, content, mode, false);
}
@Transactional
@BusinessAudit(event = "WAREHOUSE_IMPORT", resourceType = "WAREHOUSE_IMPORT", description = "批量导入仓库", captureBefore = false)
public WarehouseImportResult importFile(long tenantId, String fileName, byte[] content, ImportMode mode, boolean dryRun) {
if (tenantId != WarehouseSaveNormalizer.PUBLIC_TENANT_ID) {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共仓库作用域必须为0");
}
ImportMode resolvedMode = mode == null ? ImportMode.CREATE_ONLY : mode;
String safeFileName = safeFileName(fileName);
if (content == null || content.length == 0) return failure(safeFileName, resolvedMode, 0, "文件", "文件不能为空");
if (content.length > MAX_FILE_BYTES) return failure(safeFileName, resolvedMode, 0, "文件", "文件不能超过 5MB");
if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
return failure(safeFileName, resolvedMode, 0, "文件", "只支持 .xlsx 文件");
}
List<ImportRow> rows = new ArrayList<>();
List<WarehouseImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) return failure(safeFileName, resolvedMode, 0, "模板", "缺少仓库工作表");
XSSFSheet sheet = workbook.getSheetAt(0);
DataFormatter formatter = new DataFormatter();
validateTemplate(sheet, formatter, errors);
if (!errors.isEmpty()) return result(safeFileName, resolvedMode, 0, 0, 0, 0, List.of(), errors);
for (int index = 3; index <= sheet.getLastRowNum(); index++) {
Row row = sheet.getRow(index);
if (isBlank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) {
errors.add(new WarehouseImportError(index + 1, "文件", "单次最多导入 1000 行"));
break;
}
rows.add(new ImportRow(index + 1,
value(row, 0, formatter), value(row, 1, formatter), value(row, 2, formatter),
value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter),
value(row, 6, formatter), value(row, 7, formatter), value(row, 8, formatter),
value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter),
value(row, 12, formatter), value(row, 13, formatter), value(row, 14, formatter)));
}
} catch (IOException | RuntimeException exception) {
return failure(safeFileName, resolvedMode, rows.size(), "文件", "文件格式无法读取,请使用标准 XLSX 模板");
}
if (rows.isEmpty() && errors.isEmpty()) {
errors.add(new WarehouseImportError(0, "文件", "文件中没有可导入的数据行"));
}
List<PlanRow> plan = precheck(rows, errors, resolvedMode);
if (!errors.isEmpty()) {
return result(safeFileName, resolvedMode, rows.size(), 0, 0, 0, preview(plan), errors);
}
int plannedCreates = (int) plan.stream().filter(row -> "CREATE".equals(row.action)).count();
int plannedUpdates = (int) plan.stream().filter(row -> "UPDATE".equals(row.action)).count();
int plannedUnchanged = (int) plan.stream().filter(row -> "UNCHANGED".equals(row.action)).count();
if (dryRun) {
return result(safeFileName, resolvedMode, rows.size(), plannedCreates, plannedUpdates, plannedUnchanged,
false, preview(plan), List.of());
}
int created = 0;
int updated = 0;
int unchanged = 0;
try {
for (PlanRow row : plan) {
if ("CREATE".equals(row.action)) {
warehouseService.create(row.createRequest());
created++;
} else if ("UPDATE".equals(row.action) && resolvedMode == ImportMode.UPSERT) {
warehouseService.update(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, row.existing().getId(), row.updateRequest());
updated++;
} else if ("UNCHANGED".equals(row.action)) {
unchanged++;
}
}
} catch (DataIntegrityViolationException exception) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return failure(safeFileName, resolvedMode, rows.size(), "仓库编码", "与现有仓库重复,整批数据未写入");
}
return result(safeFileName, resolvedMode, rows.size(), created, updated, unchanged, true, preview(plan), List.of());
}
private List<PlanRow> precheck(List<ImportRow> rows, List<WarehouseImportError> errors, ImportMode mode) {
List<PlanRow> plan = new ArrayList<>();
for (ImportRow row : rows) {
validateRequired(row, rows, errors);
WarehouseRequests.SaveRequest createRequest = row.toCreateRequest();
WarehouseSaveNormalizer.NormalizedSaveRequest normalized = null;
try {
normalized = WarehouseSaveNormalizer.normalize(createRequest, WarehouseSaveNormalizer.PUBLIC_TENANT_ID);
} catch (BizException exception) {
errors.add(new WarehouseImportError(row.row(), "时区", exception.getMessage()));
}
WarehouseEntity existing = row.code().isBlank()
? null
: repository.findByCode(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, row.code());
String action = existing == null ? "CREATE" : "UPDATE";
String reason = existing == null ? "新增仓库" : "现有仓库存在差异";
if (existing != null && normalized != null) {
if (unchanged(existing, firstAddress(existing), normalized)) {
action = "UNCHANGED";
reason = "与现有仓库无变化";
} else if (mode == ImportMode.CREATE_ONLY) {
action = "UNCHANGED";
reason = "仅新增模式,现有仓库不修改";
}
}
plan.add(new PlanRow(row, existing, normalized, action, reason));
}
return plan;
}
private static void validateTemplate(XSSFSheet sheet, DataFormatter formatter, List<WarehouseImportError> errors) {
if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter))
|| !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) {
errors.add(new WarehouseImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) {
if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new WarehouseImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”"));
}
}
}
private void validateRequired(ImportRow row, List<ImportRow> rows, List<WarehouseImportError> errors) {
required(row, errors, "仓库编码", row.code(), 50);
if (row.name().isBlank() && !"FBA".equalsIgnoreCase(row.type())) {
errors.add(new WarehouseImportError(row.row(), "仓库名称", "不能为空;FBA 仓可不填并默认使用仓库编码"));
} else {
optionalLength(row, errors, "仓库名称", row.name(), 120);
}
if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS")) {
errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA 或 OVERSEAS"));
}
if (!row.countryCode().isBlank() && !row.countryCode().matches("(?i)[A-Z]{2}")) {
errors.add(new WarehouseImportError(row.row(), "国家二字码", "必须是两个英文字母"));
}
optionalLength(row, errors, "省/州", row.stateProvince(), 100);
required(row, errors, "城市", row.city(), 100);
optionalLength(row, errors, "区/县", row.district(), 100);
required(row, errors, "详细地址", row.addressLine(), 255);
optionalLength(row, errors, "邮政编码", row.postalCode(), 20);
optionalLength(row, errors, "时区", row.timezone(), 64);
if (!row.status().isBlank() && !row.status().matches("(?i)ACTIVE|INACTIVE")) {
errors.add(new WarehouseImportError(row.row(), "状态", "只能填写 ACTIVE 或 INACTIVE"));
}
optionalLength(row, errors, "联系人", row.contactName(), 64);
optionalLength(row, errors, "联系电话", row.contactPhone(), 32);
optionalLength(row, errors, "联系邮箱", row.contactEmail(), 128);
optionalLength(row, errors, "备注", row.remark(), 500);
for (ImportRow previous : rows) {
if (previous == row) break;
if (!row.code().isBlank() && previous.code().equalsIgnoreCase(row.code())) {
errors.add(new WarehouseImportError(row.row(), "仓库编码", "与文件内其他行重复"));
}
}
}
private WarehouseAddressEntity firstAddress(WarehouseEntity existing) {
return repository.findAddresses(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, existing.getId()).stream()
.findFirst()
.orElse(null);
}
private static boolean unchanged(WarehouseEntity existing, WarehouseAddressEntity address,
WarehouseSaveNormalizer.NormalizedSaveRequest normalized) {
return Objects.equals(existing.getWarehouseName(), normalized.warehouseName())
&& Objects.equals(existing.getWarehouseType(), normalized.warehouseType())
&& Objects.equals(existing.getCountryCode(), normalized.countryCode())
&& Objects.equals(existing.getTimezone(), normalized.timezone())
&& Objects.equals(existing.getStatus(), normalized.status())
&& address != null
&& Objects.equals(address.getCountryCode(), normalized.address().countryCode())
&& Objects.equals(address.getStateProvince(), normalized.address().stateProvince())
&& Objects.equals(address.getCity(), normalized.address().city())
&& Objects.equals(address.getDistrict(), normalized.address().district())
&& Objects.equals(address.getPostalCode(), normalized.address().postalCode())
&& Objects.equals(address.getAddressLine1(), normalized.address().addressLine1())
&& Objects.equals(address.getAddressLine2(), normalized.address().addressLine2());
}
private static List<WarehouseImportPreviewRow> preview(List<PlanRow> plan) {
return plan.stream()
.map(row -> new WarehouseImportPreviewRow(row.source().row(), row.source().code(), row.action(), row.reason()))
.toList();
}
private static WarehouseImportResult failure(String fileName, ImportMode mode, int total, String field, String message) {
return result(fileName, mode, total, 0, 0, 0, List.of(), List.of(new WarehouseImportError(0, field, message)));
}
private static WarehouseImportResult result(String fileName, ImportMode mode, int total, int created, int updated,
int unchanged, List<WarehouseImportPreviewRow> preview,
List<WarehouseImportError> errors) {
return result(fileName, mode, total, created, updated, unchanged, false, preview, errors);
}
private static WarehouseImportResult result(String fileName, ImportMode mode, int total, int created, int updated,
int unchanged, boolean committed, List<WarehouseImportPreviewRow> preview,
List<WarehouseImportError> errors) {
return new WarehouseImportResult(fileName, mode, total, created, updated, unchanged, errors.isEmpty(), committed, preview, errors);
}
private static void required(ImportRow row, List<WarehouseImportError> errors, String field, String value, int max) {
if (value.isBlank()) errors.add(new WarehouseImportError(row.row(), field, "不能为空"));
else optionalLength(row, errors, field, value, max);
}
private static void optionalLength(ImportRow row, List<WarehouseImportError> errors, String field, String value, int max) {
if (value.length() > max) errors.add(new WarehouseImportError(row.row(), field, "不能超过 " + max + " 个字符"));
}
private static String value(Row row, int index, DataFormatter formatter) {
if (row == null) return "";
Cell cell = row.getCell(index);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private static boolean isBlank(Row row, DataFormatter formatter) {
if (row == null) return true;
for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false;
return true;
}
private static String safeFileName(String value) {
if (value == null || value.isBlank()) return "未命名文件.xlsx";
String normalized = value.replace('\\', '/');
return normalized.substring(normalized.lastIndexOf('/') + 1);
}
private record PlanRow(ImportRow source, WarehouseEntity existing,
WarehouseSaveNormalizer.NormalizedSaveRequest normalized,
String action, String reason) {
WarehouseRequests.SaveRequest createRequest() {
return source.toCreateRequest();
}
WarehouseRequests.SaveRequest updateRequest() {
String status = "INACTIVE".equals(existing.getStatus()) ? existing.getStatus() : source.status();
return source.toRequest(existing.getContactName(), existing.getContactPhone(), existing.getContactEmail(),
existing.getRemark(), status);
}
}
private record ImportRow(int row, String code, String name, String type, String countryCode, String stateProvince,
String city, String district, String addressLine, String postalCode, String timezone,
String status, String contactName, String contactPhone, String contactEmail, String remark) {
WarehouseRequests.SaveRequest toCreateRequest() {
return toRequest(contactName, contactPhone, contactEmail, remark, status);
}
WarehouseRequests.SaveRequest toRequest(String resolvedContactName, String resolvedContactPhone,
String resolvedContactEmail, String resolvedRemark, String resolvedStatus) {
String warehouseType = StringUtils.hasText(type) ? type.trim().toUpperCase(Locale.ROOT) : null;
String resolvedCountry = StringUtils.hasText(countryCode) ? countryCode.trim().toUpperCase(Locale.ROOT) : null;
String resolvedName = StringUtils.hasText(name) ? name : code;
return new WarehouseRequests.SaveRequest(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, code, resolvedName, warehouseType,
resolvedCountry, WarehouseSaveNormalizer.trimToNull(timezone),
WarehouseSaveNormalizer.trimToNull(resolvedContactName),
WarehouseSaveNormalizer.trimToNull(resolvedContactPhone),
WarehouseSaveNormalizer.trimToNull(resolvedContactEmail),
StringUtils.hasText(resolvedStatus) ? resolvedStatus.trim().toUpperCase(Locale.ROOT) : null,
WarehouseSaveNormalizer.trimToNull(resolvedRemark),
new WarehouseRequests.AddressRequest(null, resolvedCountry, stateProvince, city, district, postalCode,
addressLine, null, null, null),
List.of());
}
}
}
@@ -0,0 +1,162 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseRequests;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** 仓库轻量新增/导入共用默认规则。 */
final class WarehouseSaveNormalizer {
static final long PUBLIC_TENANT_ID = 0L;
static final String TYPE_DOMESTIC = "DOMESTIC";
static final String TYPE_FBA = "FBA";
static final String STATUS_ACTIVE = "ACTIVE";
static final String DEFAULT_ADDRESS_TYPE = "WAREHOUSE";
static final String DEFAULT_DOMESTIC_COUNTRY = "CN";
static final String DEFAULT_DOMESTIC_TIMEZONE = "UTC+8";
private static final Map<String, String> US_SINGLE_TIMEZONE_STATES = Map.ofEntries(
Map.entry("AL", "UTC-6"), Map.entry("ALABAMA", "UTC-6"),
Map.entry("AK", "UTC-9"), Map.entry("ALASKA", "UTC-9"),
Map.entry("AZ", "UTC-7"), Map.entry("ARIZONA", "UTC-7"),
Map.entry("CA", "UTC-8"), Map.entry("CALIFORNIA", "UTC-8"),
Map.entry("CO", "UTC-7"), Map.entry("COLORADO", "UTC-7"),
Map.entry("CT", "UTC-5"), Map.entry("CONNECTICUT", "UTC-5"),
Map.entry("DE", "UTC-5"), Map.entry("DELAWARE", "UTC-5"),
Map.entry("GA", "UTC-5"), Map.entry("GEORGIA", "UTC-5"),
Map.entry("HI", "UTC-10"), Map.entry("HAWAII", "UTC-10"),
Map.entry("IA", "UTC-6"), Map.entry("IOWA", "UTC-6"),
Map.entry("IL", "UTC-6"), Map.entry("ILLINOIS", "UTC-6"),
Map.entry("LA", "UTC-6"), Map.entry("LOUISIANA", "UTC-6"),
Map.entry("MA", "UTC-5"), Map.entry("MASSACHUSETTS", "UTC-5"),
Map.entry("MD", "UTC-5"), Map.entry("MARYLAND", "UTC-5"),
Map.entry("ME", "UTC-5"), Map.entry("MAINE", "UTC-5"),
Map.entry("MN", "UTC-6"), Map.entry("MINNESOTA", "UTC-6"),
Map.entry("MO", "UTC-6"), Map.entry("MISSOURI", "UTC-6"),
Map.entry("MS", "UTC-6"), Map.entry("MISSISSIPPI", "UTC-6"),
Map.entry("MT", "UTC-7"), Map.entry("MONTANA", "UTC-7"),
Map.entry("NC", "UTC-5"), Map.entry("NORTH CAROLINA", "UTC-5"),
Map.entry("NH", "UTC-5"), Map.entry("NEW HAMPSHIRE", "UTC-5"),
Map.entry("NJ", "UTC-5"), Map.entry("NEW JERSEY", "UTC-5"),
Map.entry("NM", "UTC-7"), Map.entry("NEW MEXICO", "UTC-7"),
Map.entry("NV", "UTC-8"), Map.entry("NEVADA", "UTC-8"),
Map.entry("NY", "UTC-5"), Map.entry("NEW YORK", "UTC-5"),
Map.entry("OH", "UTC-5"), Map.entry("OHIO", "UTC-5"),
Map.entry("OK", "UTC-6"), Map.entry("OKLAHOMA", "UTC-6"),
Map.entry("PA", "UTC-5"), Map.entry("PENNSYLVANIA", "UTC-5"),
Map.entry("RI", "UTC-5"), Map.entry("RHODE ISLAND", "UTC-5"),
Map.entry("SC", "UTC-5"), Map.entry("SOUTH CAROLINA", "UTC-5"),
Map.entry("VA", "UTC-5"), Map.entry("VIRGINIA", "UTC-5"),
Map.entry("VT", "UTC-5"), Map.entry("VERMONT", "UTC-5"),
Map.entry("WA", "UTC-8"), Map.entry("WASHINGTON", "UTC-8"),
Map.entry("WI", "UTC-6"), Map.entry("WISCONSIN", "UTC-6"),
Map.entry("WV", "UTC-5"), Map.entry("WEST VIRGINIA", "UTC-5"),
Map.entry("WY", "UTC-7"), Map.entry("WYOMING", "UTC-7")
);
private static final Set<String> US_MULTI_TIMEZONE_STATES = Set.of(
"FL", "FLORIDA", "ID", "IDAHO", "IN", "INDIANA", "KS", "KANSAS", "KY", "KENTUCKY",
"MI", "MICHIGAN", "ND", "NORTH DAKOTA", "NE", "NEBRASKA", "OR", "OREGON",
"SD", "SOUTH DAKOTA", "TN", "TENNESSEE", "TX", "TEXAS"
);
private WarehouseSaveNormalizer() {
}
static NormalizedSaveRequest normalize(WarehouseRequests.SaveRequest request, long tenantId) {
String warehouseType = upperOrDefault(request.warehouseType(), TYPE_DOMESTIC);
if (!TYPE_DOMESTIC.equals(warehouseType) && !TYPE_FBA.equals(warehouseType) && !"OVERSEAS".equals(warehouseType)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse type must be DOMESTIC, FBA or OVERSEAS / 仓库类型只能为 DOMESTIC、FBA 或 OVERSEAS");
}
String status = upperOrDefault(request.status(), STATUS_ACTIVE);
if (!STATUS_ACTIVE.equals(status) && !"INACTIVE".equals(status)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse status must be ACTIVE or INACTIVE / 仓库状态只能为 ACTIVE 或 INACTIVE");
}
WarehouseRequests.AddressRequest address = request.address();
if (address == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Primary address is required / 主地址不能为空");
}
String countryCode = upperOrDefault(firstText(request.countryCode(), address.countryCode()),
TYPE_DOMESTIC.equals(warehouseType) ? DEFAULT_DOMESTIC_COUNTRY : null);
if (!StringUtils.hasText(countryCode)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Country code is required / 国家二字码不能为空");
}
if (!countryCode.matches("[A-Z]{2}")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Country code must be two uppercase letters / 国家二字码必须为两个大写字母");
}
String timezone = resolveTimezone(warehouseType, countryCode, request.timezone(), address.stateProvince());
WarehouseRequests.AddressRequest normalizedAddress = new WarehouseRequests.AddressRequest(
upperOrDefault(address.addressType(), DEFAULT_ADDRESS_TYPE),
upperOrDefault(address.countryCode(), countryCode),
trimToNull(address.stateProvince()), address.city(), trimToNull(address.district()),
trimToNull(address.postalCode()), address.addressLine1(), trimToNull(address.addressLine2()),
address.latitude(), address.longitude());
List<WarehouseRequests.CapabilityRequest> capabilities = TYPE_FBA.equals(warehouseType)
? List.of()
: request.capabilities();
return new NormalizedSaveRequest(tenantId, request.warehouseCode().trim(), request.warehouseName().trim(),
warehouseType, countryCode, timezone, trimToNull(request.contactName()), trimToNull(request.contactPhone()),
trimToNull(request.contactEmail()), status, trimToNull(request.remark()), normalizedAddress, capabilities);
}
static String resolveTimezone(String warehouseType, String countryCode, String explicitTimezone, String stateProvince) {
if (StringUtils.hasText(explicitTimezone)) {
return explicitTimezone.trim();
}
if (TYPE_DOMESTIC.equals(warehouseType) && DEFAULT_DOMESTIC_COUNTRY.equals(countryCode)) {
return DEFAULT_DOMESTIC_TIMEZONE;
}
if (TYPE_FBA.equals(warehouseType) && "US".equals(countryCode)) {
String stateKey = normalizeState(stateProvince);
if (US_MULTI_TIMEZONE_STATES.contains(stateKey)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"FBA warehouse in multi-timezone state requires explicit timezone / 多时区州的 FBA 仓需要显式确认时区");
}
String timezone = US_SINGLE_TIMEZONE_STATES.get(stateKey);
if (timezone != null) {
return timezone;
}
}
throw new BizException(ResultCode.VALIDATION_FAILED,
"Timezone is required unless a confirmed default rule applies / 未命中明确默认规则时必须显式填写时区");
}
static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static String firstText(String left, String right) {
return StringUtils.hasText(left) ? left : right;
}
private static String upperOrDefault(String value, String defaultValue) {
String resolved = StringUtils.hasText(value) ? value.trim() : defaultValue;
return resolved == null ? null : resolved.toUpperCase(Locale.ROOT);
}
private static String normalizeState(String value) {
return StringUtils.hasText(value)
? value.trim().replace(".", "").toUpperCase(Locale.ROOT)
: "";
}
record NormalizedSaveRequest(
long tenantId,
String warehouseCode,
String warehouseName,
String warehouseType,
String countryCode,
String timezone,
String contactName,
String contactPhone,
String contactEmail,
String status,
String remark,
WarehouseRequests.AddressRequest address,
List<WarehouseRequests.CapabilityRequest> capabilities
) {
}
}
@@ -0,0 +1,253 @@
source_row,warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id
238,193000000000000001,193000000000100001,ABE2,ABE2,US,PA,Breinigsville,18031,UTC-5,705 Boulder Drive,
237,193000000000000002,193000000000100002,ABE3,ABE3,US,PA,Breinigsville,18031,UTC-5,650 Boulder Drive,
134,193000000000000003,193000000000100003,ABE4,ABE4,US,PA,Easton,18045,UTC-5,1610 Van Buren Road,126
231,193000000000000004,193000000000100004,ABE5,ABE5,US,PA,Harrisburg,17112,UTC-5,6455 Allentown Boulevard,
125,193000000000000005,193000000000100005,ABE8,ABE8,US,NJ,Florence,08518,UTC-5,401 Independence Road 08518-2200,39
99,193000000000000006,193000000000100006,ABQ2,ABQ2,US,NM,LOS LUNAS,87031,UTC-7,6251 Pioneer Trail NW,76
84,193000000000000007,193000000000100007,ACY2,ACY2,US,NJ,BURLINGTON,08016,UTC-5,1101 E PEARL ST,101
141,193000000000000008,193000000000100008,AFW1,AFW1,US,TX,FORT WORTH,76131,UTC-6,1851 NE LOOP 820,
27,193000000000000009,193000000000100009,AFW5,AFW5,US,TX,BALCH SPRINGS,75180-1636,UTC-6,12374 LAKE JUNE RD,181
148,193000000000000010,193000000000100010,AKR1,AKR1,US,OH,Canton,44705,UTC-5,4747 Rebar Ave NE,153
174,193000000000000011,193000000000100011,ALB1,ALB1,US,NY,Castleton,12033-9634,UTC-5,1835 US Route 9,102
129,193000000000000012,193000000000100012,AMA1,AMA1,US,TX,Amarillo,79108,UTC-6,8590 NE 24th Avenue,132
31,193000000000000013,193000000000100013,ATL2N,ATL2N,US,GA,Carrollton,30117,UTC-5,3101 N. Highway 27,157
147,193000000000000014,193000000000100014,ATL3,ATL3,US,GA,Pendergrass,30567,UTC-5,445 Valentine Industrial Parkway,
202,193000000000000015,193000000000100015,ATL6,ATL6,US,GA,East Point,30344,UTC-5,4200 North Commerce,
233,193000000000000016,193000000000100016,AVP1,AVP1,US,PA,Hazle Township,18202-9361,UTC-5,550 Oak Ridge Road,70
232,193000000000000017,193000000000100017,AVP3,AVP3,US,PA,GOULDSBORO,18424-9492,UTC-5,298 1ST AVE,
289,193000000000000018,193000000000100018,BDL1,BDL1,US,CT,Windsor,06095,UTC-5,801 Day Hill Rd.,
208,193000000000000019,193000000000100019,BFI1,BFI1,US,WA,Sumner,98390,UTC-8,1800 140th Avenue E.,
207,193000000000000020,193000000000100020,BFI3,BFI3,US,WA,Dupont,98327-9607,UTC-8,2700 Center Drive,34
206,193000000000000021,193000000000100021,BFI7,BFI7,US,WA,Sumner,98390,UTC-8,1901 140th Ave E,
11,193000000000000022,193000000000100022,BJC1,BJC1,US,CO,AURORA,80019,UTC-7,6300 N Powhaton Road,186
221,193000000000000023,193000000000100023,BNA1,BNA1,US,TN,Lebanon,37090,UTC-6,14840 Central Pike Suite 190,
131,193000000000000024,193000000000100024,BNA2,BNA2,US,TN,Lebanon,37090,UTC-6,500 Duke DR,37
140,193000000000000025,193000000000100025,BNA3,BNA3,US,TN,Murfreesboro,37127,UTC-6,2020 Joe B Jackson Pkwy,
220,193000000000000026,193000000000100026,BNA4,BNA4,US,TN,Nashville,37217,UTC-6,50 airways Blvd,
110,193000000000000027,193000000000100027,BNA6,BNA6,US,TN,CLARKSVILLE,37040-5502,UTC-6,3875 GUTHRIE HWY,99
243,193000000000000028,193000000000100028,BOS1,BOS1,US,NH,Nashua,03063,UTC-5,10 State St.,
193,193000000000000029,193000000000100029,BOS2,BOS2,US,MA,Drive Stoughton,02072,UTC-5,1000 Tech Centre,
192,193000000000000030,193000000000100030,BOS7,BOS7,US,MA,Fall River,02720,UTC-5,1180 Innovation Way,103
242,193000000000000031,193000000000100031,BWI1,BWI1,US,VA,Sterling,20166-2058,UTC-5,45121 Global Plaza,
241,193000000000000032,193000000000100032,BWI2,BWI2,US,MD,Baltimore,21224,UTC-5,2010 Broening Hwy,
102,193000000000000033,193000000000100033,BWI4,BWI4,US,VA,CLEARBROOK,22624,UTC-5,165 Business Blvd,150
225,193000000000000034,193000000000100034,CAE1,CAE1,US,SC,West Columbia,29172,UTC-5,4400 12TH STREET EXT,
223,193000000000000035,193000000000100035,CHA1,CHA1,US,TN,Chattanooga,37416,UTC-5,7200 Discovery Drive,
222,193000000000000036,193000000000100036,CHA2,CHA2,US,TN,Charleston,37310,UTC-5,225 Infinity Dr NW,66
75,193000000000000037,193000000000100037,CHO1,CHO1,US,VA,FISHERSVILLE,22939,UTC-5,32 TRADER RD,120
191,193000000000000038,193000000000100038,CLT2,CLT2,US,NC,Charlotte,28214-8082,UTC-5,10240 Old Dowd Rd,59
114,193000000000000039,193000000000100039,CLT3,CLT3,US,NC,CONCORD,28027,UTC-5,6500 Davidson Hwy,104
240,193000000000000040,193000000000100040,CMH2,CMH2,US,OH,Groveport,43125-9016,UTC-5,6050 Gateway Court,123
181,193000000000000041,193000000000100041,CMH3,CMH3,US,OH,Monroe,45050-1848,UTC-5,700 GATEWAY BLVD,32
262,193000000000000042,193000000000100042,CVG1,CVG1,US,KY,Hebron,41048,UTC-5,1155 Worldwide Blvd,
261,193000000000000043,193000000000100043,CVG2,CVG2,US,KY,Hebron,41048,UTC-5,1600 Worldwide Blvd,
260,193000000000000044,193000000000100044,CVG3,CVG3,US,KY,Hebron,41048,UTC-5,3680 Langley Dr.,
259,193000000000000045,193000000000100045,CVG5,CVG5,US,KY,Hebron,41048,UTC-5,2285 Litton Lane,
135,193000000000000046,193000000000100046,DCA6,DCA6,US,MD,BALTIMORE,21219,UTC-5,6001 Bethlehem Blvd,135
123,193000000000000047,193000000000100047,DEN2,DEN2,US,CO,Aurora,80019,UTC-7,22205 East 19th Ave,107
120,193000000000000048,193000000000100048,DEN8,DEN8,US,CO,Aurora,80018,UTC-7,21000 E 13th Ave,73
189,193000000000000049,193000000000100049,DET1,DET1,US,MI,Livonia,48150-1543,UTC-5,39000 Amrhein Road,118
182,193000000000000050,193000000000100050,DET2,DET2,US,MI,Shelby Township,48317-1318,UTC-5,50500 Mound Rd,136
143,193000000000000051,193000000000100051,DFW5S,DFW5S,US,TX,Lancaster,75146,UTC-6,"2500 E Belt Line Rd,",
219,193000000000000052,193000000000100052,DFW6,DFW6,US,TX,Coppell,75019,UTC-6,940 W Bethel Road,105
217,193000000000000053,193000000000100053,DFW8,DFW8,US,TX,DFW Airport,75261,UTC-6,2700 Regent Blvd,
256,193000000000000054,193000000000100054,EWR4,EWR4,US,NJ,Robbinsville,08691,UTC-5,50 New Canton Way,
255,193000000000000055,193000000000100055,EWR5,EWR5,US,NJ,Avenel,07001,UTC-5,301 Blair Rd.,
254,193000000000000056,193000000000100056,EWR7,EWR7,US,NJ,Avenel,07001,UTC-5,301 Blair Rd.,
253,193000000000000057,193000000000100057,EWR9,EWR9,US,NJ,Carteret,07008,UTC-5,8003 Industrial Parkway,
117,193000000000000058,193000000000100058,FAT2,FAT2,US,CA,VISALIA,93291-1882,UTC-8,3315 N KELSEYST,43
176,193000000000000059,193000000000100059,FOE1,FOE1,US,KS,KANSAS CITY,66109-3665,UTC-6,9400 LEAVENWORTH RD,117
212,193000000000000060,193000000000100060,FTW1,FTW1,US,TX,Dallas,75241,UTC-6,33333 Lyndon B Johnson FREEWAY,24
211,193000000000000061,193000000000100061,FTW2,FTW2,US,TX,Coppell,75019,UTC-6,2701 West Bethel Road,
89,193000000000000062,193000000000100062,FTW3,FTW3,US,TX,Fort Worth,76177-2517,UTC-6,15201 Heritage Pkwy,
121,193000000000000063,193000000000100063,FTW5,FTW5,US,TX,Forney,75126,UTC-6,1475 AKRON WAY,48
171,193000000000000064,193000000000100064,FWA4,FWA4,US,IN,FORT WAYNE,46809-9771,UTC-5,9798 Smith Road,58
151,193000000000000065,193000000000100065,GEG2,GEG2,US,WA,SPOKANE,99216,UTC-8,18007 E Garland Ave,108
67,193000000000000066,193000000000100066,GEU2,GEU2,US,AZ,Glendale,85307,UTC-7,15301 West Northern Avenue,97
78,193000000000000067,193000000000100067,GEU3,GEU3,US,AZ,BUCKEYE,85396,UTC-7,18900 W McDowell Road,81
68,193000000000000068,193000000000100068,GEU5,GEU5,US,AZ,GOODYEAR,85395,UTC-7,4660 North Cotton Lane,100
164,193000000000000069,193000000000100069,GSO1,GSO1,US,NC,KERNERSVILLE,27284,UTC-5,1656 OLD GREENSBORO RD,131
224,193000000000000070,193000000000100070,GSP1,GSP1,US,SC,Spartanburg,29303,UTC-5,402 John Dodd Rd,90
188,193000000000000071,193000000000100071,GYR2,GYR2,US,AZ,GOODYEAR,85395-7672,UTC-7,17341 W MINNEZONA AVE,7
187,193000000000000072,193000000000100072,GYR3,GYR3,US,AZ,PHOENIX,85043-2356,UTC-7,8181 W ROOSEVELT ST,8
80,193000000000000073,193000000000100073,HGR6,HGR6,US,MD,HAGERSTOWN,21740-7301,UTC-5,55 West Oak Ridge Drive,130
87,193000000000000074,193000000000100074,HIA1,HIA1,US,PA,MIDDLETOWN,17057,UTC-5,3327 E Harrisburg Pik,148
44,193000000000000075,193000000000100075,HLI2,HLI2,US,CA,HOLLISTER,95023,UTC-8,600 Ernie Drive,137
216,193000000000000076,193000000000100076,HOU1,HOU1,US,TX,Humble,77338,UTC-6,8120 Humble Westfield Rd,
215,193000000000000077,193000000000100077,HOU2,HOU2,US,TX,Houston,77038-2324,UTC-6,10550 Ella Blvd.,
214,193000000000000078,193000000000100078,HOU3,HOU3,US,TX,Brookshire,77423,UTC-6,31555 Highway 90 E,
154,193000000000000079,193000000000100079,HOU8,HOU8,US,TX,FRESNO,77545,UTC-6,2303 Hurricane Lane,49
142,193000000000000080,193000000000100080,HSV1,HSV1,US,AL,Madison,35756,UTC-6,7817 Greenbrier Road,80
126,193000000000000081,193000000000100081,HSV2,HSV2,US,AL,MADISON,35756,UTC-6,28869 Fanning Dr NW,
155,193000000000000082,193000000000100082,IAH3,IAH3,US,TX,Houston,77032,UTC-6,15525 Milner Road,57
3,193000000000000083,193000000000100083,IAZ1,IAZ1,US,AZ,GOODYEAR,85395-4515,UTC-7,"4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States",179
136,193000000000000084,193000000000100084,ICT2,ICT2,US,KS,PARK CITY,67219,UTC-6,7130 N BROADWAY AVE,109
2,193000000000000085,193000000000100085,IGA3,IGA3,US,GA,MACON,31216-6427,UTC-5,"7001 Skipper Rd 31216-6427 MACON, GA - United States",
152,193000000000000086,193000000000100086,IGQ2,IGQ2,US,IL,University park,60484,UTC-6,23257 S. Central Avenue,110
119,193000000000000087,193000000000100087,ILG1,ILG1,US,DE,NEW CASTLE,19720,UTC-5,780 S. DuPont Highway,35
10,193000000000000088,193000000000100088,IMO1,IMO1,US,MO,LIBERTY,64068-8933,UTC-6,2361 S Withers RD,178
6,193000000000000089,193000000000100089,IMS1,IMS1,US,MS,OLIVE BRANCH,38654,UTC-6,IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US,189
5,193000000000000090,193000000000100090,INC1,INC1,US,NC,FAYETTEVILLE,28303-2215,UTC-5,"6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States",190
88,193000000000000091,193000000000100091,IND2,IND2,US,IN,Plainfield,46168-7442,UTC-5,715 Airtech Pkwy,127
276,193000000000000092,193000000000100092,IND4,IND4,US,IN,Indianapolis,46231,UTC-5,710 S. Girls School Rd,
275,193000000000000093,193000000000100093,IND5,IND5,US,IN,Plainfield,46168,UTC-5,800 Perry Road,140
274,193000000000000094,193000000000100094,IND6,IND6,US,IN,Jeffersonville,47130,UTC-5,1453 E 10th St,
173,193000000000000095,193000000000100095,IND9,IND9,US,IN,GREENWOOD,46143-7830,UTC-5,1151 S GRAHAM RD,17
9,193000000000000096,193000000000100096,ITX3,ITX3,US,TX,AMARILLO,79108-7749,UTC-6,"ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US",187
22,193000000000000097,193000000000100097,IUSF,IUSF,US,TX,DALLAS,75241,UTC-6,3351 Balmorhea Dr,169
69,193000000000000098,193000000000100098,IUSJ,IUSJ,US,CA,BEAUMONT,92223,UTC-8,36900 West 4th Street,98
74,193000000000000099,193000000000100099,IUSP,IUSP,US,CA,"HESPERIA,",92344,UTC-8,8140 Caliente Rd,83
70,193000000000000100,193000000000100100,IUSQ,IUSQ,US,CA,REDLANDS,92374,UTC-8,2125 W San Bernardino Ave,95
40,193000000000000101,193000000000100101,IUSR,IUSR,US,SC,COWPENS,29330,UTC-5,1120 Mt Olive Rd,158
39,193000000000000102,193000000000100102,IUST,IUST,US,PA,GREENCASTLE,17225,UTC-5,"3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States",149
33,193000000000000103,193000000000100103,IUSW,IUSW,US,CA,Hesperia,92344,UTC-8,"8130 Caliente Rd 92344 - Hesperia, CA - United States",156
21,193000000000000104,193000000000100104,IUTE,IUTE,US,AZ,GOODYEAR,85338,UTC-7,16155 W Elwood St,170
8,193000000000000105,193000000000100105,IUTJ,IUTJ,US,CA,Fontana,92335,UTC-8,"9253 Dreamland Drive 92335 - Fontana, CA - United",177
7,193000000000000106,193000000000100106,IUTL,IUTL,US,CA,Moreno Valley,92551,UTC-8,"24405 Krameria Ave 92551 - Moreno Valley, CA - United States",188
4,193000000000000107,193000000000100107,IWA6,IWA6,US,WA,PASCO,99301-6161,UTC-8,"1202 S Road 40 E 99301-6161 - PASCO, WA - United States",191
72,193000000000000108,193000000000100108,JOT1,JOT1,US,CA,SAN BERNARDINO,92408,UTC-8,1494 S WATERMAN AVE,93
150,193000000000000109,193000000000100109,JVL1,JVL1,US,WI,BELOIT,53511,UTC-6,1255 Gateway Blvd,125
178,193000000000000110,193000000000100110,KRB4,KRB4,US,CA,PERRIS,92571-9722,UTC-8,4501 PATTERSON AVE,
161,193000000000000111,193000000000100111,KY1,KY1,US,KY,Shepherdsville,40165,UTC-5,"120 Velocity Way.,",60
100,193000000000000112,193000000000100112,LAN2,LAN2,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy,96
247,193000000000000113,193000000000100113,LAS1,LAS1,US,NV,HENDERSON,89044,UTC-8,12300 Bermuda Road,5
246,193000000000000114,193000000000100114,LAS2,LAS2,US,NV,Las Vegas,89030,UTC-8,"3837 Bay Lake Trail, Suite 111 North",
132,193000000000000115,193000000000100115,LAS6,LAS6,US,NV,Las Vegas,89115,UTC-8,4550 Nexus Way,50
162,193000000000000116,193000000000100116,LAX2T,LAX2T,US,CA,CHINO,91708,UTC-8,6730 Kimball Ave,19
183,193000000000000117,193000000000100117,LAX9,LAX9,US,CA,FONTANA,92337,UTC-8,11263 Oleander Ave,3
86,193000000000000118,193000000000100118,LBE1,LBE1,US,PA,Greensburg,15605,UTC-5,165 Glen Fox Road,112
268,193000000000000119,193000000000100119,LEX1,LEX1,US,KY,Lexington,40511,UTC-5,1850 Mercer RD,
267,193000000000000120,193000000000100120,LEX2,LEX2,US,KY,Lexington,40511,UTC-5,172 Trade St.,
107,193000000000000121,193000000000100121,LFT1,LFT1,US,LA,CARENCRO,70520-5976,UTC-6,3550 NE EVANGELINE TRWY,31
291,193000000000000122,193000000000100122,LGB3,LGB3,US,CA,Eastvale,91752,UTC-8,4950 Goodman Way,
292,193000000000000123,193000000000100123,LGB4,LGB4,US,CA,Redlands,92374,UTC-8,27517 Pioneer Avenue,45
293,193000000000000124,193000000000100124,LGB6,LGB6,US,CA,Riverside,92518-1513,UTC-8,20901 Krameria Ave,26
294,193000000000000125,193000000000100125,LGB8,LGB8,US,CA,Rialto,92376,UTC-8,1568 N Linden Ave,2
106,193000000000000126,193000000000100126,LIT2,LIT2,US,AR,NORTH LITTLE ROCK,72117,UTC-6,13001 HIGHWAY 70,159
32,193000000000000127,193000000000100127,MCC1,MCC1,US,CA,RANCHO CORDOVA,95742,UTC-8,"3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States",161
157,193000000000000128,193000000000100128,MCE1,MCE1,US,CA,TURLOCK,95380,UTC-8,FULKERTH RD,65
271,193000000000000129,193000000000100129,MCI1,MCI1,US,KS,Lenexa,66219,UTC-6,113th St & Renner Blvd.,
12,193000000000000130,193000000000100130,MCI4,MCI4,US,MO,KANSAS CITY,64163,UTC-6,MCI4 - 10501 NW 136th Street,176
115,193000000000000131,193000000000100131,MCO2,MCO2,US,FL,Deltona,32725-2610,UTC-5,2600 N Normandy Blvd,106
230,193000000000000132,193000000000100132,MDT1,MDT1,US,PA,Carlisle,17015,UTC-5,2Ames Drive,121
128,193000000000000133,193000000000100133,MDT4,MDT4,US,PA,York,17408,UTC-5,5125 Commerce Drive,144
200,193000000000000134,193000000000100134,MDW2,MDW2,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive,46
199,193000000000000135,193000000000100135,MDW6,MDW6,US,IL,ROMEOVILLE,60446-6529,UTC-6,1125 W REMINGTON BLVD ROMEOVILLE,143
198,193000000000000136,193000000000100136,MDW7,MDW7,US,IL,Monee,60449,UTC-6,6605 Monee Manhattan Road,
197,193000000000000137,193000000000100137,MDW8,MDW8,US,IL,Waukegan,60085,UTC-6,1750 Bridge Drive,
196,193000000000000138,193000000000100138,MDW9,MDW9,US,IL,Aurora,60502,UTC-6,2865 Duke Parkway,36
163,193000000000000139,193000000000100139,MEM1,MEM1,US,TN,Memphis,38118,UTC-6,3292 E Holmes Rd,85
118,193000000000000140,193000000000100140,MEM6,MEM6,US,MS,OLIVE BRANCH,38654,UTC-6,11505 Progress Way,51
201,193000000000000141,193000000000100141,MGE3,MGE3,US,GA,Jefferson,30549-2010,UTC-5,808 Hog Mountain Road,133
77,193000000000000142,193000000000100142,MIT2,MIT2,US,CA,SHAFTER,93263,UTC-8,5408 Express Avenue,82
270,193000000000000143,193000000000100143,MKC4,MKC4,US,KS,Edgerton,66021-9588,UTC-6,19645 Waverly Rd,67
269,193000000000000144,193000000000100144,MKC6,MKC6,US,KS,Kansas City,66102,UTC-6,6925 Riverview Avenue,
203,193000000000000145,193000000000100145,MKE1,MKE1,US,WI,Kenosha,53144,UTC-6,3501 120th Ave,
94,193000000000000146,193000000000100146,MQJ1,MQJ1,US,IN,GREENFIELD,46140,UTC-5,4412 W 300 RD N,88
190,193000000000000147,193000000000100147,MSP1,MSP1,US,MN,Shakopee,55379,UTC-6,2601 4th Avenue East,
17,193000000000000148,193000000000100148,NJ3,NJ3,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way,175
299,193000000000000149,193000000000100149,OAK3,OAK3,US,CA,Patterson,95363-8876,UTC-8,255 Park Center Drive,41
300,193000000000000150,193000000000100150,OAK4,OAK4,US,CA,Tracy,95304-9370,UTC-8,1555 N. Chrisman Rd,
133,193000000000000151,193000000000100151,OKC2,OKC2,US,OK,OKLAHOMA CITY,73159,UTC-6,8991 S PORTLAND AVENUE,33
19,193000000000000152,193000000000100152,OLM1,OLM1,US,WA,OLYMPIA,98516,UTC-8,"3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States",172
303,193000000000000153,193000000000100153,ONT2,ONT2,US,CA,San Bernardino,92408,UTC-8,1910 E Central Ave,
302,193000000000000154,193000000000100154,ONT6,ONT6,US,CA,Moreno Valley,92551-9561,UTC-8,24208 San Michele Rd,
301,193000000000000155,193000000000100155,ONT8,ONT8,US,CA,Moreno Valley,92551-9534,UTC-8,24300 Nandina Ave,1
298,193000000000000156,193000000000100156,ONT9,ONT9,US,CA,Redlands,92374,UTC-8,2125 W. San Bernandino Ave,25
103,193000000000000157,193000000000100157,ORD2,ORD2,US,IL,CHANNAHON,60410-8758,UTC-6,23714 W Amoco Rd,21
195,193000000000000158,193000000000100158,ORD6,ORD6,US,IL,Wood Dale,60191,UTC-6,1250 N Mittel Blvd,
113,193000000000000159,193000000000100159,ORF2,ORF2,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD,38
81,193000000000000160,193000000000100160,PBI3,PBI3,US,FL,Jacksonville,32206,UTC-5,21 Jump Street,113
127,193000000000000161,193000000000100161,PDX7,PDX7,US,OR,Salem,97317,UTC-8,4775 Depot Ct SE,68
288,193000000000000162,193000000000100162,PHL1,PHL1,US,DE,New Castle,19720,UTC-5,1 Centerpoint Blvd.,
287,193000000000000163,193000000000100163,PHL3,PHL3,US,DE,New Castle,19720,UTC-5,1600 Johnson Way,
236,193000000000000164,193000000000100164,PHL4,PHL4,US,PA,Carlisle,17015,UTC-5,21 Roadway Dr,52
235,193000000000000165,193000000000100165,PHL5,PHL5,US,PA,Lewisberry,17339,UTC-5,500 McCarthy Dr,
20,193000000000000166,193000000000100166,PHL5S,PHL5S,US,PA,Greencastle,17225,UTC-5,"1915 Ebberts Spg Ct, Greencastle, PA 17225",171
234,193000000000000167,193000000000100167,PHL6,PHL6,US,PA,Carlisle,17015,UTC-5,675 Allen Rd.,69
286,193000000000000168,193000000000100168,PHL7,PHL7,US,DE,Middletown,19709,UTC-5,560 Merrimac Ave,
285,193000000000000169,193000000000100169,PHL8,PHL8,US,DE,Middletown,19709,UTC-5,727 N Broad St,
307,193000000000000170,193000000000100170,PHX3,PHX3,US,AZ,Phoenix,85043,UTC-7,6835 West Buckeye Road,
306,193000000000000171,193000000000100171,PHX5,PHX5,US,AZ,Goodyear,85338,UTC-7,16920 W Commerce Drive,72
305,193000000000000172,193000000000100172,PHX6,PHX6,US,AZ,Phoenix,85043,UTC-7,4750 West Mohave St,
304,193000000000000173,193000000000100173,PHX7,PHX7,US,AZ,Phoenix,85043,UTC-7,800 N 75th Ave,74
229,193000000000000174,193000000000100174,PIT1,PIT1,US,PA,Pittsburgh,15205,UTC-5,2250 Roswell Drive,
158,193000000000000175,193000000000100175,PIT2,PIT2,US,PA,IMPERIAL,15126-1002,UTC-5,1200 Westport Rd,78
46,193000000000000176,193000000000100176,POC1,POC1,US,CA,FONTANA,92336-1123,UTC-8,4121 LYTLE CREEK RD,128
38,193000000000000177,193000000000100177,POC2,POC2,US,CA,ONTARIO,91761,UTC-8,4000 South Hamner Avenue,151
45,193000000000000178,193000000000100178,POC3,POC3,US,CA,JURUPA VALLEY,92509,UTC-8,6120 Clinker Drive,129
49,193000000000000179,193000000000100179,PPO4,PPO4,US,IN,MERRILLVILLE,46410,UTC-6,9850 Mississippi Street,122
93,193000000000000180,193000000000100180,PSC2,PSC2,US,WA,PASCO,99301,UTC-8,1351 S Road 40 E,75
28,193000000000000181,193000000000100181,PSP3,PSP3,US,CA,DESERT HOT SPRINGS,92240,UTC-8,64165 19th AVE,162
71,193000000000000182,193000000000100182,QXY5,QXY5,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,92
35,193000000000000183,193000000000100183,QXY8,QXY8,US,CA,FRESNO,93725-1962,UTC-8,3611 S NORTHPOINTE DR,154
14,193000000000000184,193000000000100184,QZZ7,QZZ7,US,AZ,MESA,85212-9608,UTC-7,8560 E ELLIOT RD,184
146,193000000000000185,193000000000100185,RDU2,RDU2,US,NC,Smithfield,27577,UTC-5,2150 US HWY 70 Business West,56
95,193000000000000186,193000000000100186,RDU4,RDU4,US,NC,FAYETTEVILLE,28303,UTC-5,6309 Bragg Blvd,145
145,193000000000000187,193000000000100187,RFD2,RFD2,US,IL,Huntley,60142,UTC-6,11500 Freeman Road,71
16,193000000000000188,193000000000100188,RFD4,RFD4,US,IL,HUNTLEY,60142,UTC-6,"11400 Venture Court 60142-8075 - HUNTLEY, IL - United States",174
210,193000000000000189,193000000000100189,RIC1,RIC1,US,VA,Petersburg,23803,UTC-5,5000 Commerce Way,142
209,193000000000000190,193000000000100190,RIC2,RIC2,US,VA,Chester,23836,UTC-5,1901 Meadowville Technology Pkwy,
144,193000000000000191,193000000000100191,RMN3,RMN3,US,VA,Fredericksburg,22406,UTC-5,220 Centreport Parkway,84
248,193000000000000192,193000000000100192,RNO1,RNO1,US,NV,Fernley,89408,UTC-8,1600 East Newlands RD,
245,193000000000000193,193000000000100193,RNO2,RNO2,US,NV,Reno,85906,UTC-8,8000 North Virginia Street,
244,193000000000000194,193000000000100194,RNO4,RNO4,US,NV,Reno,89506,UTC-8,8000 North Virginia Street,
73,193000000000000195,193000000000100195,RYY2,RYY2,US,GA,White,30184,UTC-5,"1250 Cassville White Road,",94
218,193000000000000196,193000000000100196,SAT1,SAT1,US,TX,Schertz,78154-1461,UTC-6,6000 Enterprise Avenue,23
149,193000000000000197,193000000000100197,SAT4,SAT4,US,TX,SAN ANTONIO,78245,UTC-6,10384 W US HIGHWAY 90,61
15,193000000000000198,193000000000100198,SAT6,SAT6,US,TX,SAN MARCOS,78666,UTC-6,"1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States",183
98,193000000000000199,193000000000100199,SAV3,SAV3,US,GA,Macon,31216-6427,UTC-5,7001 Skipper Rd,124
185,193000000000000200,193000000000100200,SBD1,SBD1,US,CA,BLOOMINGTON,92316-3819,UTC-8,3388 S Cactus Ave,4
179,193000000000000201,193000000000100201,SBD2,SBD2,US,CA,SAN BERNARDINO,92408-2805,UTC-8,1494 S WATERMAN AVE,15
97,193000000000000202,193000000000100202,SCK1,SCK1,US,CA,STOCKTON,95215,UTC-8,4532 NEWCASTLE RD,20
60,193000000000000203,193000000000100203,SCK3,SCK3,US,CA,MANTECA,95336,UTC-8,3565 N AIRPORT WAY MANTECA,54
186,193000000000000204,193000000000100204,SCK4,SCK4,US,CA,STOCKTON,95215-8354,UTC-8,6001 S AUSTIN RD,10
104,193000000000000205,193000000000100205,SCK8,SCK8,US,CA,OAKLEY,94561-2677,UTC-8,4700 WILBUR AVE,141
266,193000000000000206,193000000000100206,SDF1,SDF1,US,KY,Campbellsville,42718,UTC-5,1050 South Columbia AVE,
265,193000000000000207,193000000000100207,SDF2,SDF2,US,KY,Watterson Park,40218,UTC-5,4360 Robards Ln,
264,193000000000000208,193000000000100208,SDF4,SDF4,US,KY,Shepherdsville,40165,UTC-5,376 Zappos.com Blvd,
263,193000000000000209,193000000000100209,SDF6,SDF6,US,KY,Shepherdsville,40165,UTC-5,271 Omega Pkwy,
258,193000000000000210,193000000000100210,SDF7,SDF7,US,KY,Shepherdsville,40165,UTC-5,300 Omicron Court,
273,193000000000000211,193000000000100211,SDF8,SDF8,US,IN,Jeffersonville,47130,UTC-5,900 Patrol Rd,
257,193000000000000212,193000000000100212,SDF9,SDF9,US,KY,Shepherdsville,40165,UTC-5,100 W. Thomas P. Echols Lane,
205,193000000000000213,193000000000100213,SEA6,SEA6,US,WA,South Seattle,98144,UTC-8,2646 Rainier Ave.,
204,193000000000000214,193000000000100214,SEA8,SEA8,US,WA,Northeast Bellevue,98005,UTC-8,1475 124th Avenue,
295,193000000000000215,193000000000100215,SJC7,SJC7,US,CA,Tracy,95377-8906,UTC-8,188 Mountain House Parkway,30
153,193000000000000216,193000000000100216,SLC2,SLC2,US,UT,West Jordan,84081,UTC-7,7148 W. Old Bingham Hwy,86
13,193000000000000217,193000000000100217,SLC3,SLC3,US,UT,SALT LAKE CITY,84116-4413,UTC-7,355 N JOHN GLENN RD,185
184,193000000000000218,193000000000100218,SMF3,SMF3,US,CA,Stockton,95206-8202,UTC-8,3923 S B ST,9
105,193000000000000219,193000000000100219,SMF6,SMF6,US,CA,SACRAMENTO,95837-9109,UTC-8,4930 ALLBAUGH DR,28
296,193000000000000220,193000000000100220,SNA4,SNA4,US,CA,Rialto,92376-3009,UTC-8,2496 W Walnut Ave,18
109,193000000000000221,193000000000100221,STL3,STL3,US,MO,REPUBLIC,65738-2779,UTC-6,3200 E SAWYER RD,152
194,193000000000000222,193000000000100222,STL4,STL4,US,IL,Edwardsville,62025-2815,UTC-6,3050 GATEWAY COMMERCE CENTER DR S,115
122,193000000000000223,193000000000100223,SWF1,SWF1,US,NY,ROCK TAVERN,12575,UTC-5,635 INTERNATIONAL BLVD,134
76,193000000000000224,193000000000100224,SWF2,SWF2,US,NY,Hopewell Junction,12533,UTC-5,76 Patriot Way Hopewell Junction,62
41,193000000000000225,193000000000100225,TCY1,TCY1,US,CA,STOCKTON,95206,UTC-8,2690 East Arch Airport Road,139
43,193000000000000226,193000000000100226,TCY2,TCY2,US,CA,STOCKTON,95215,UTC-8,6201 Newcastle Rd,138
250,193000000000000227,193000000000100227,TEB3,TEB3,US,NJ,Logan Township,08085,UTC-5,2651 Oldmans Creek Rd,47
130,193000000000000228,193000000000100228,TEB4,TEB4,US,NJ,PENNS GROVE,08069,UTC-5,747 COURSES LANDING RD,55
249,193000000000000229,193000000000100229,TEB6,TEB6,US,NJ,Cranbury,08512,UTC-5,22 Hightstown Cranbury State Road,114
156,193000000000000230,193000000000100230,TEB9,TEB9,US,NJ,Somerset,08873,UTC-5,601 Randolph Road,40
65,193000000000000231,193000000000100231,TMB8,TMB8,US,FL,HOMESTEAD,33032-8597,UTC-5,27505 SW 132nd AVE,116
18,193000000000000232,193000000000100232,TOL3,TOL3,US,OH,PERRYSBURG,43551,UTC-5,"9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States",173
284,193000000000000233,193000000000100233,TPA1,TPA1,US,FL,Ruskin,33570,UTC-5,3350 Laurel Ridge Ave.,
283,193000000000000234,193000000000100234,TPA2,TPA2,US,FL,Lakeland,33811,UTC-5,1760 County Line Rd,22
124,193000000000000235,193000000000100235,TPA3,TPA3,US,FL,Auburndale,33823,UTC-5,676 C. Fred Jones Boulevard,146
272,193000000000000236,193000000000100236,TUL1,TUL1,US,KS,Coffeyville,67337,UTC-6,2654 North Highway 169,
172,193000000000000237,193000000000100237,VGT2,VGT2,US,NV,LAS VEGAS,89115-2013,UTC-8,6401 Howdy Wells Ave,6
228,193000000000000238,193000000000100238,VUBA,VUBA,US,PA,Scranton,18512,UTC-5,1000 Keystone Industrial Park,
227,193000000000000239,193000000000100239,VUGA,VUGA,US,PA,Pittston,18643,UTC-5,508 Delaware Avenue West,
82,193000000000000240,193000000000100240,WBW2,WBW2,US,PA,OLYPHANT,18447,UTC-5,1300 Corporate Way,119
36,193000000000000241,193000000000100241,XAV3,XAV3,US,GA,PORT WENTWORTH,31407,UTC-5,"XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States",180
37,193000000000000242,193000000000100242,XEW5,XEW5,US,NJ,Cinnaminson,08077-2015,UTC-5,995 Taylors Ln 08077-2015,163
25,193000000000000243,193000000000100243,XIN5,XIN5,US,IN,Avon,46123-1700,UTC-5,8838 E COUNTY ROAD 100 S,182
34,193000000000000244,193000000000100244,XLX1,XLX1,US,MD,HAGERSTOWN,21740,UTC-5,"1115 Wesel Blvd HAGERSTOWN,MD 21740,USA",160
23,193000000000000245,193000000000100245,XLX6,XLX6,US,NC,PINEVILLE,28134,UTC-5,"12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA",166
63,193000000000000246,193000000000100246,XLX7,XLX7,US,CA,VICTORVILLE,92394,UTC-8,"18580 Gateway Dr,Victorville,CA 92394,USA",27
26,193000000000000247,193000000000100247,XPB2,XPB2,US,FL,APOPKA,32712,UTC-5,"XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States",165
24,193000000000000248,193000000000100248,XRI3,XRI3,US,VA,Richmond,23234,UTC-5,"4949 Commerce Rd, Richmond, VA 23234",167
213,193000000000000249,193000000000100249,XUSB,XUSB,US,TX,Fort Worth,76155,UTC-6,14900 Frye Rd.,
226,193000000000000250,193000000000100250,XUSC,XUSC,US,PA,Carlisle,17013,UTC-5,40 Logistics Drive,
297,193000000000000251,193000000000100251,XUSD,XUSD,US,CA,Stockton,95206,UTC-8,1909 Zephyr St,
280,193000000000000252,193000000000100252,XUSE,XUSE,US,IN,Whitestown,46075,UTC-5,5100 S Indianapolis Rd,
1 source_row warehouse_id_seed address_id_seed warehouse_code warehouse_name country_code state_province city postal_code timezone address_line1 source_internal_id
2 238 193000000000000001 193000000000100001 ABE2 ABE2 US PA Breinigsville 18031 UTC-5 705 Boulder Drive
3 237 193000000000000002 193000000000100002 ABE3 ABE3 US PA Breinigsville 18031 UTC-5 650 Boulder Drive
4 134 193000000000000003 193000000000100003 ABE4 ABE4 US PA Easton 18045 UTC-5 1610 Van Buren Road 126
5 231 193000000000000004 193000000000100004 ABE5 ABE5 US PA Harrisburg 17112 UTC-5 6455 Allentown Boulevard
6 125 193000000000000005 193000000000100005 ABE8 ABE8 US NJ Florence 08518 UTC-5 401 Independence Road 08518-2200 39
7 99 193000000000000006 193000000000100006 ABQ2 ABQ2 US NM LOS LUNAS 87031 UTC-7 6251 Pioneer Trail NW 76
8 84 193000000000000007 193000000000100007 ACY2 ACY2 US NJ BURLINGTON 08016 UTC-5 1101 E PEARL ST 101
9 141 193000000000000008 193000000000100008 AFW1 AFW1 US TX FORT WORTH 76131 UTC-6 1851 NE LOOP 820
10 27 193000000000000009 193000000000100009 AFW5 AFW5 US TX BALCH SPRINGS 75180-1636 UTC-6 12374 LAKE JUNE RD 181
11 148 193000000000000010 193000000000100010 AKR1 AKR1 US OH Canton 44705 UTC-5 4747 Rebar Ave NE 153
12 174 193000000000000011 193000000000100011 ALB1 ALB1 US NY Castleton 12033-9634 UTC-5 1835 US Route 9 102
13 129 193000000000000012 193000000000100012 AMA1 AMA1 US TX Amarillo 79108 UTC-6 8590 NE 24th Avenue 132
14 31 193000000000000013 193000000000100013 ATL2N ATL2N US GA Carrollton 30117 UTC-5 3101 N. Highway 27 157
15 147 193000000000000014 193000000000100014 ATL3 ATL3 US GA Pendergrass 30567 UTC-5 445 Valentine Industrial Parkway
16 202 193000000000000015 193000000000100015 ATL6 ATL6 US GA East Point 30344 UTC-5 4200 North Commerce
17 233 193000000000000016 193000000000100016 AVP1 AVP1 US PA Hazle Township 18202-9361 UTC-5 550 Oak Ridge Road 70
18 232 193000000000000017 193000000000100017 AVP3 AVP3 US PA GOULDSBORO 18424-9492 UTC-5 298 1ST AVE
19 289 193000000000000018 193000000000100018 BDL1 BDL1 US CT Windsor 06095 UTC-5 801 Day Hill Rd.
20 208 193000000000000019 193000000000100019 BFI1 BFI1 US WA Sumner 98390 UTC-8 1800 140th Avenue E.
21 207 193000000000000020 193000000000100020 BFI3 BFI3 US WA Dupont 98327-9607 UTC-8 2700 Center Drive 34
22 206 193000000000000021 193000000000100021 BFI7 BFI7 US WA Sumner 98390 UTC-8 1901 140th Ave E
23 11 193000000000000022 193000000000100022 BJC1 BJC1 US CO AURORA 80019 UTC-7 6300 N Powhaton Road 186
24 221 193000000000000023 193000000000100023 BNA1 BNA1 US TN Lebanon 37090 UTC-6 14840 Central Pike Suite 190
25 131 193000000000000024 193000000000100024 BNA2 BNA2 US TN Lebanon 37090 UTC-6 500 Duke DR 37
26 140 193000000000000025 193000000000100025 BNA3 BNA3 US TN Murfreesboro 37127 UTC-6 2020 Joe B Jackson Pkwy
27 220 193000000000000026 193000000000100026 BNA4 BNA4 US TN Nashville 37217 UTC-6 50 airways Blvd
28 110 193000000000000027 193000000000100027 BNA6 BNA6 US TN CLARKSVILLE 37040-5502 UTC-6 3875 GUTHRIE HWY 99
29 243 193000000000000028 193000000000100028 BOS1 BOS1 US NH Nashua 03063 UTC-5 10 State St.
30 193 193000000000000029 193000000000100029 BOS2 BOS2 US MA Drive Stoughton 02072 UTC-5 1000 Tech Centre
31 192 193000000000000030 193000000000100030 BOS7 BOS7 US MA Fall River 02720 UTC-5 1180 Innovation Way 103
32 242 193000000000000031 193000000000100031 BWI1 BWI1 US VA Sterling 20166-2058 UTC-5 45121 Global Plaza
33 241 193000000000000032 193000000000100032 BWI2 BWI2 US MD Baltimore 21224 UTC-5 2010 Broening Hwy
34 102 193000000000000033 193000000000100033 BWI4 BWI4 US VA CLEARBROOK 22624 UTC-5 165 Business Blvd 150
35 225 193000000000000034 193000000000100034 CAE1 CAE1 US SC West Columbia 29172 UTC-5 4400 12TH STREET EXT
36 223 193000000000000035 193000000000100035 CHA1 CHA1 US TN Chattanooga 37416 UTC-5 7200 Discovery Drive
37 222 193000000000000036 193000000000100036 CHA2 CHA2 US TN Charleston 37310 UTC-5 225 Infinity Dr NW 66
38 75 193000000000000037 193000000000100037 CHO1 CHO1 US VA FISHERSVILLE 22939 UTC-5 32 TRADER RD 120
39 191 193000000000000038 193000000000100038 CLT2 CLT2 US NC Charlotte 28214-8082 UTC-5 10240 Old Dowd Rd 59
40 114 193000000000000039 193000000000100039 CLT3 CLT3 US NC CONCORD 28027 UTC-5 6500 Davidson Hwy 104
41 240 193000000000000040 193000000000100040 CMH2 CMH2 US OH Groveport 43125-9016 UTC-5 6050 Gateway Court 123
42 181 193000000000000041 193000000000100041 CMH3 CMH3 US OH Monroe 45050-1848 UTC-5 700 GATEWAY BLVD 32
43 262 193000000000000042 193000000000100042 CVG1 CVG1 US KY Hebron 41048 UTC-5 1155 Worldwide Blvd
44 261 193000000000000043 193000000000100043 CVG2 CVG2 US KY Hebron 41048 UTC-5 1600 Worldwide Blvd
45 260 193000000000000044 193000000000100044 CVG3 CVG3 US KY Hebron 41048 UTC-5 3680 Langley Dr.
46 259 193000000000000045 193000000000100045 CVG5 CVG5 US KY Hebron 41048 UTC-5 2285 Litton Lane
47 135 193000000000000046 193000000000100046 DCA6 DCA6 US MD BALTIMORE 21219 UTC-5 6001 Bethlehem Blvd 135
48 123 193000000000000047 193000000000100047 DEN2 DEN2 US CO Aurora 80019 UTC-7 22205 East 19th Ave 107
49 120 193000000000000048 193000000000100048 DEN8 DEN8 US CO Aurora 80018 UTC-7 21000 E 13th Ave 73
50 189 193000000000000049 193000000000100049 DET1 DET1 US MI Livonia 48150-1543 UTC-5 39000 Amrhein Road 118
51 182 193000000000000050 193000000000100050 DET2 DET2 US MI Shelby Township 48317-1318 UTC-5 50500 Mound Rd 136
52 143 193000000000000051 193000000000100051 DFW5S DFW5S US TX Lancaster 75146 UTC-6 2500 E Belt Line Rd,
53 219 193000000000000052 193000000000100052 DFW6 DFW6 US TX Coppell 75019 UTC-6 940 W Bethel Road 105
54 217 193000000000000053 193000000000100053 DFW8 DFW8 US TX DFW Airport 75261 UTC-6 2700 Regent Blvd
55 256 193000000000000054 193000000000100054 EWR4 EWR4 US NJ Robbinsville 08691 UTC-5 50 New Canton Way
56 255 193000000000000055 193000000000100055 EWR5 EWR5 US NJ Avenel 07001 UTC-5 301 Blair Rd.
57 254 193000000000000056 193000000000100056 EWR7 EWR7 US NJ Avenel 07001 UTC-5 301 Blair Rd.
58 253 193000000000000057 193000000000100057 EWR9 EWR9 US NJ Carteret 07008 UTC-5 8003 Industrial Parkway
59 117 193000000000000058 193000000000100058 FAT2 FAT2 US CA VISALIA 93291-1882 UTC-8 3315 N KELSEYST 43
60 176 193000000000000059 193000000000100059 FOE1 FOE1 US KS KANSAS CITY 66109-3665 UTC-6 9400 LEAVENWORTH RD 117
61 212 193000000000000060 193000000000100060 FTW1 FTW1 US TX Dallas 75241 UTC-6 33333 Lyndon B Johnson FREEWAY 24
62 211 193000000000000061 193000000000100061 FTW2 FTW2 US TX Coppell 75019 UTC-6 2701 West Bethel Road
63 89 193000000000000062 193000000000100062 FTW3 FTW3 US TX Fort Worth 76177-2517 UTC-6 15201 Heritage Pkwy
64 121 193000000000000063 193000000000100063 FTW5 FTW5 US TX Forney 75126 UTC-6 1475 AKRON WAY 48
65 171 193000000000000064 193000000000100064 FWA4 FWA4 US IN FORT WAYNE 46809-9771 UTC-5 9798 Smith Road 58
66 151 193000000000000065 193000000000100065 GEG2 GEG2 US WA SPOKANE 99216 UTC-8 18007 E Garland Ave 108
67 67 193000000000000066 193000000000100066 GEU2 GEU2 US AZ Glendale 85307 UTC-7 15301 West Northern Avenue 97
68 78 193000000000000067 193000000000100067 GEU3 GEU3 US AZ BUCKEYE 85396 UTC-7 18900 W McDowell Road 81
69 68 193000000000000068 193000000000100068 GEU5 GEU5 US AZ GOODYEAR 85395 UTC-7 4660 North Cotton Lane 100
70 164 193000000000000069 193000000000100069 GSO1 GSO1 US NC KERNERSVILLE 27284 UTC-5 1656 OLD GREENSBORO RD 131
71 224 193000000000000070 193000000000100070 GSP1 GSP1 US SC Spartanburg 29303 UTC-5 402 John Dodd Rd 90
72 188 193000000000000071 193000000000100071 GYR2 GYR2 US AZ GOODYEAR 85395-7672 UTC-7 17341 W MINNEZONA AVE 7
73 187 193000000000000072 193000000000100072 GYR3 GYR3 US AZ PHOENIX 85043-2356 UTC-7 8181 W ROOSEVELT ST 8
74 80 193000000000000073 193000000000100073 HGR6 HGR6 US MD HAGERSTOWN 21740-7301 UTC-5 55 West Oak Ridge Drive 130
75 87 193000000000000074 193000000000100074 HIA1 HIA1 US PA MIDDLETOWN 17057 UTC-5 3327 E Harrisburg Pik 148
76 44 193000000000000075 193000000000100075 HLI2 HLI2 US CA HOLLISTER 95023 UTC-8 600 Ernie Drive 137
77 216 193000000000000076 193000000000100076 HOU1 HOU1 US TX Humble 77338 UTC-6 8120 Humble Westfield Rd
78 215 193000000000000077 193000000000100077 HOU2 HOU2 US TX Houston 77038-2324 UTC-6 10550 Ella Blvd.
79 214 193000000000000078 193000000000100078 HOU3 HOU3 US TX Brookshire 77423 UTC-6 31555 Highway 90 E
80 154 193000000000000079 193000000000100079 HOU8 HOU8 US TX FRESNO 77545 UTC-6 2303 Hurricane Lane 49
81 142 193000000000000080 193000000000100080 HSV1 HSV1 US AL Madison 35756 UTC-6 7817 Greenbrier Road 80
82 126 193000000000000081 193000000000100081 HSV2 HSV2 US AL MADISON 35756 UTC-6 28869 Fanning Dr NW
83 155 193000000000000082 193000000000100082 IAH3 IAH3 US TX Houston 77032 UTC-6 15525 Milner Road 57
84 3 193000000000000083 193000000000100083 IAZ1 IAZ1 US AZ GOODYEAR 85395-4515 UTC-7 4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States 179
85 136 193000000000000084 193000000000100084 ICT2 ICT2 US KS PARK CITY 67219 UTC-6 7130 N BROADWAY AVE 109
86 2 193000000000000085 193000000000100085 IGA3 IGA3 US GA MACON 31216-6427 UTC-5 7001 Skipper Rd 31216-6427 MACON, GA - United States
87 152 193000000000000086 193000000000100086 IGQ2 IGQ2 US IL University park 60484 UTC-6 23257 S. Central Avenue 110
88 119 193000000000000087 193000000000100087 ILG1 ILG1 US DE NEW CASTLE 19720 UTC-5 780 S. DuPont Highway 35
89 10 193000000000000088 193000000000100088 IMO1 IMO1 US MO LIBERTY 64068-8933 UTC-6 2361 S Withers RD 178
90 6 193000000000000089 193000000000100089 IMS1 IMS1 US MS OLIVE BRANCH 38654 UTC-6 IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US 189
91 5 193000000000000090 193000000000100090 INC1 INC1 US NC FAYETTEVILLE 28303-2215 UTC-5 6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States 190
92 88 193000000000000091 193000000000100091 IND2 IND2 US IN Plainfield 46168-7442 UTC-5 715 Airtech Pkwy 127
93 276 193000000000000092 193000000000100092 IND4 IND4 US IN Indianapolis 46231 UTC-5 710 S. Girls School Rd
94 275 193000000000000093 193000000000100093 IND5 IND5 US IN Plainfield 46168 UTC-5 800 Perry Road 140
95 274 193000000000000094 193000000000100094 IND6 IND6 US IN Jeffersonville 47130 UTC-5 1453 E 10th St
96 173 193000000000000095 193000000000100095 IND9 IND9 US IN GREENWOOD 46143-7830 UTC-5 1151 S GRAHAM RD 17
97 9 193000000000000096 193000000000100096 ITX3 ITX3 US TX AMARILLO 79108-7749 UTC-6 ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US 187
98 22 193000000000000097 193000000000100097 IUSF IUSF US TX DALLAS 75241 UTC-6 3351 Balmorhea Dr 169
99 69 193000000000000098 193000000000100098 IUSJ IUSJ US CA BEAUMONT 92223 UTC-8 36900 West 4th Street 98
100 74 193000000000000099 193000000000100099 IUSP IUSP US CA HESPERIA, 92344 UTC-8 8140 Caliente Rd 83
101 70 193000000000000100 193000000000100100 IUSQ IUSQ US CA REDLANDS 92374 UTC-8 2125 W San Bernardino Ave 95
102 40 193000000000000101 193000000000100101 IUSR IUSR US SC COWPENS 29330 UTC-5 1120 Mt Olive Rd 158
103 39 193000000000000102 193000000000100102 IUST IUST US PA GREENCASTLE 17225 UTC-5 3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States 149
104 33 193000000000000103 193000000000100103 IUSW IUSW US CA Hesperia 92344 UTC-8 8130 Caliente Rd 92344 - Hesperia, CA - United States 156
105 21 193000000000000104 193000000000100104 IUTE IUTE US AZ GOODYEAR 85338 UTC-7 16155 W Elwood St 170
106 8 193000000000000105 193000000000100105 IUTJ IUTJ US CA Fontana 92335 UTC-8 9253 Dreamland Drive 92335 - Fontana, CA - United 177
107 7 193000000000000106 193000000000100106 IUTL IUTL US CA Moreno Valley 92551 UTC-8 24405 Krameria Ave 92551 - Moreno Valley, CA - United States 188
108 4 193000000000000107 193000000000100107 IWA6 IWA6 US WA PASCO 99301-6161 UTC-8 1202 S Road 40 E 99301-6161 - PASCO, WA - United States 191
109 72 193000000000000108 193000000000100108 JOT1 JOT1 US CA SAN BERNARDINO 92408 UTC-8 1494 S WATERMAN AVE 93
110 150 193000000000000109 193000000000100109 JVL1 JVL1 US WI BELOIT 53511 UTC-6 1255 Gateway Blvd 125
111 178 193000000000000110 193000000000100110 KRB4 KRB4 US CA PERRIS 92571-9722 UTC-8 4501 PATTERSON AVE
112 161 193000000000000111 193000000000100111 KY1 KY1 US KY Shepherdsville 40165 UTC-5 120 Velocity Way., 60
113 100 193000000000000112 193000000000100112 LAN2 LAN2 US MI LANSING 48917 UTC-5 6500 W Mt Hope Hwy 96
114 247 193000000000000113 193000000000100113 LAS1 LAS1 US NV HENDERSON 89044 UTC-8 12300 Bermuda Road 5
115 246 193000000000000114 193000000000100114 LAS2 LAS2 US NV Las Vegas 89030 UTC-8 3837 Bay Lake Trail, Suite 111 North
116 132 193000000000000115 193000000000100115 LAS6 LAS6 US NV Las Vegas 89115 UTC-8 4550 Nexus Way 50
117 162 193000000000000116 193000000000100116 LAX2T LAX2T US CA CHINO 91708 UTC-8 6730 Kimball Ave 19
118 183 193000000000000117 193000000000100117 LAX9 LAX9 US CA FONTANA 92337 UTC-8 11263 Oleander Ave 3
119 86 193000000000000118 193000000000100118 LBE1 LBE1 US PA Greensburg 15605 UTC-5 165 Glen Fox Road 112
120 268 193000000000000119 193000000000100119 LEX1 LEX1 US KY Lexington 40511 UTC-5 1850 Mercer RD
121 267 193000000000000120 193000000000100120 LEX2 LEX2 US KY Lexington 40511 UTC-5 172 Trade St.
122 107 193000000000000121 193000000000100121 LFT1 LFT1 US LA CARENCRO 70520-5976 UTC-6 3550 NE EVANGELINE TRWY 31
123 291 193000000000000122 193000000000100122 LGB3 LGB3 US CA Eastvale 91752 UTC-8 4950 Goodman Way
124 292 193000000000000123 193000000000100123 LGB4 LGB4 US CA Redlands 92374 UTC-8 27517 Pioneer Avenue 45
125 293 193000000000000124 193000000000100124 LGB6 LGB6 US CA Riverside 92518-1513 UTC-8 20901 Krameria Ave 26
126 294 193000000000000125 193000000000100125 LGB8 LGB8 US CA Rialto 92376 UTC-8 1568 N Linden Ave 2
127 106 193000000000000126 193000000000100126 LIT2 LIT2 US AR NORTH LITTLE ROCK 72117 UTC-6 13001 HIGHWAY 70 159
128 32 193000000000000127 193000000000100127 MCC1 MCC1 US CA RANCHO CORDOVA 95742 UTC-8 3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States 161
129 157 193000000000000128 193000000000100128 MCE1 MCE1 US CA TURLOCK 95380 UTC-8 FULKERTH RD 65
130 271 193000000000000129 193000000000100129 MCI1 MCI1 US KS Lenexa 66219 UTC-6 113th St & Renner Blvd.
131 12 193000000000000130 193000000000100130 MCI4 MCI4 US MO KANSAS CITY 64163 UTC-6 MCI4 - 10501 NW 136th Street 176
132 115 193000000000000131 193000000000100131 MCO2 MCO2 US FL Deltona 32725-2610 UTC-5 2600 N Normandy Blvd 106
133 230 193000000000000132 193000000000100132 MDT1 MDT1 US PA Carlisle 17015 UTC-5 2Ames Drive 121
134 128 193000000000000133 193000000000100133 MDT4 MDT4 US PA York 17408 UTC-5 5125 Commerce Drive 144
135 200 193000000000000134 193000000000100134 MDW2 MDW2 US IL Joliet 60433-3280 UTC-6 250 Emerald Drive 46
136 199 193000000000000135 193000000000100135 MDW6 MDW6 US IL ROMEOVILLE 60446-6529 UTC-6 1125 W REMINGTON BLVD ROMEOVILLE 143
137 198 193000000000000136 193000000000100136 MDW7 MDW7 US IL Monee 60449 UTC-6 6605 Monee Manhattan Road
138 197 193000000000000137 193000000000100137 MDW8 MDW8 US IL Waukegan 60085 UTC-6 1750 Bridge Drive
139 196 193000000000000138 193000000000100138 MDW9 MDW9 US IL Aurora 60502 UTC-6 2865 Duke Parkway 36
140 163 193000000000000139 193000000000100139 MEM1 MEM1 US TN Memphis 38118 UTC-6 3292 E Holmes Rd 85
141 118 193000000000000140 193000000000100140 MEM6 MEM6 US MS OLIVE BRANCH 38654 UTC-6 11505 Progress Way 51
142 201 193000000000000141 193000000000100141 MGE3 MGE3 US GA Jefferson 30549-2010 UTC-5 808 Hog Mountain Road 133
143 77 193000000000000142 193000000000100142 MIT2 MIT2 US CA SHAFTER 93263 UTC-8 5408 Express Avenue 82
144 270 193000000000000143 193000000000100143 MKC4 MKC4 US KS Edgerton 66021-9588 UTC-6 19645 Waverly Rd 67
145 269 193000000000000144 193000000000100144 MKC6 MKC6 US KS Kansas City 66102 UTC-6 6925 Riverview Avenue
146 203 193000000000000145 193000000000100145 MKE1 MKE1 US WI Kenosha 53144 UTC-6 3501 120th Ave
147 94 193000000000000146 193000000000100146 MQJ1 MQJ1 US IN GREENFIELD 46140 UTC-5 4412 W 300 RD N 88
148 190 193000000000000147 193000000000100147 MSP1 MSP1 US MN Shakopee 55379 UTC-6 2601 4th Avenue East
149 17 193000000000000148 193000000000100148 NJ3 NJ3 US NJ Pedricktown 08067 UTC-5 3 Sorbello Way 175
150 299 193000000000000149 193000000000100149 OAK3 OAK3 US CA Patterson 95363-8876 UTC-8 255 Park Center Drive 41
151 300 193000000000000150 193000000000100150 OAK4 OAK4 US CA Tracy 95304-9370 UTC-8 1555 N. Chrisman Rd
152 133 193000000000000151 193000000000100151 OKC2 OKC2 US OK OKLAHOMA CITY 73159 UTC-6 8991 S PORTLAND AVENUE 33
153 19 193000000000000152 193000000000100152 OLM1 OLM1 US WA OLYMPIA 98516 UTC-8 3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States 172
154 303 193000000000000153 193000000000100153 ONT2 ONT2 US CA San Bernardino 92408 UTC-8 1910 E Central Ave
155 302 193000000000000154 193000000000100154 ONT6 ONT6 US CA Moreno Valley 92551-9561 UTC-8 24208 San Michele Rd
156 301 193000000000000155 193000000000100155 ONT8 ONT8 US CA Moreno Valley 92551-9534 UTC-8 24300 Nandina Ave 1
157 298 193000000000000156 193000000000100156 ONT9 ONT9 US CA Redlands 92374 UTC-8 2125 W. San Bernandino Ave 25
158 103 193000000000000157 193000000000100157 ORD2 ORD2 US IL CHANNAHON 60410-8758 UTC-6 23714 W Amoco Rd 21
159 195 193000000000000158 193000000000100158 ORD6 ORD6 US IL Wood Dale 60191 UTC-6 1250 N Mittel Blvd
160 113 193000000000000159 193000000000100159 ORF2 ORF2 US VA CHESAPEAKE 23321-1425 UTC-5 ORF2 - 5045 PORTSMOUTH BLVD 38
161 81 193000000000000160 193000000000100160 PBI3 PBI3 US FL Jacksonville 32206 UTC-5 21 Jump Street 113
162 127 193000000000000161 193000000000100161 PDX7 PDX7 US OR Salem 97317 UTC-8 4775 Depot Ct SE 68
163 288 193000000000000162 193000000000100162 PHL1 PHL1 US DE New Castle 19720 UTC-5 1 Centerpoint Blvd.
164 287 193000000000000163 193000000000100163 PHL3 PHL3 US DE New Castle 19720 UTC-5 1600 Johnson Way
165 236 193000000000000164 193000000000100164 PHL4 PHL4 US PA Carlisle 17015 UTC-5 21 Roadway Dr 52
166 235 193000000000000165 193000000000100165 PHL5 PHL5 US PA Lewisberry 17339 UTC-5 500 McCarthy Dr
167 20 193000000000000166 193000000000100166 PHL5S PHL5S US PA Greencastle 17225 UTC-5 1915 Ebberts Spg Ct, Greencastle, PA 17225 171
168 234 193000000000000167 193000000000100167 PHL6 PHL6 US PA Carlisle 17015 UTC-5 675 Allen Rd. 69
169 286 193000000000000168 193000000000100168 PHL7 PHL7 US DE Middletown 19709 UTC-5 560 Merrimac Ave
170 285 193000000000000169 193000000000100169 PHL8 PHL8 US DE Middletown 19709 UTC-5 727 N Broad St
171 307 193000000000000170 193000000000100170 PHX3 PHX3 US AZ Phoenix 85043 UTC-7 6835 West Buckeye Road
172 306 193000000000000171 193000000000100171 PHX5 PHX5 US AZ Goodyear 85338 UTC-7 16920 W Commerce Drive 72
173 305 193000000000000172 193000000000100172 PHX6 PHX6 US AZ Phoenix 85043 UTC-7 4750 West Mohave St
174 304 193000000000000173 193000000000100173 PHX7 PHX7 US AZ Phoenix 85043 UTC-7 800 N 75th Ave 74
175 229 193000000000000174 193000000000100174 PIT1 PIT1 US PA Pittsburgh 15205 UTC-5 2250 Roswell Drive
176 158 193000000000000175 193000000000100175 PIT2 PIT2 US PA IMPERIAL 15126-1002 UTC-5 1200 Westport Rd 78
177 46 193000000000000176 193000000000100176 POC1 POC1 US CA FONTANA 92336-1123 UTC-8 4121 LYTLE CREEK RD 128
178 38 193000000000000177 193000000000100177 POC2 POC2 US CA ONTARIO 91761 UTC-8 4000 South Hamner Avenue 151
179 45 193000000000000178 193000000000100178 POC3 POC3 US CA JURUPA VALLEY 92509 UTC-8 6120 Clinker Drive 129
180 49 193000000000000179 193000000000100179 PPO4 PPO4 US IN MERRILLVILLE 46410 UTC-6 9850 Mississippi Street 122
181 93 193000000000000180 193000000000100180 PSC2 PSC2 US WA PASCO 99301 UTC-8 1351 S Road 40 E 75
182 28 193000000000000181 193000000000100181 PSP3 PSP3 US CA DESERT HOT SPRINGS 92240 UTC-8 64165 19th AVE 162
183 71 193000000000000182 193000000000100182 QXY5 QXY5 US CA STOCKTON 95215 UTC-8 4532 NEWCASTLE RD 92
184 35 193000000000000183 193000000000100183 QXY8 QXY8 US CA FRESNO 93725-1962 UTC-8 3611 S NORTHPOINTE DR 154
185 14 193000000000000184 193000000000100184 QZZ7 QZZ7 US AZ MESA 85212-9608 UTC-7 8560 E ELLIOT RD 184
186 146 193000000000000185 193000000000100185 RDU2 RDU2 US NC Smithfield 27577 UTC-5 2150 US HWY 70 Business West 56
187 95 193000000000000186 193000000000100186 RDU4 RDU4 US NC FAYETTEVILLE 28303 UTC-5 6309 Bragg Blvd 145
188 145 193000000000000187 193000000000100187 RFD2 RFD2 US IL Huntley 60142 UTC-6 11500 Freeman Road 71
189 16 193000000000000188 193000000000100188 RFD4 RFD4 US IL HUNTLEY 60142 UTC-6 11400 Venture Court 60142-8075 - HUNTLEY, IL - United States 174
190 210 193000000000000189 193000000000100189 RIC1 RIC1 US VA Petersburg 23803 UTC-5 5000 Commerce Way 142
191 209 193000000000000190 193000000000100190 RIC2 RIC2 US VA Chester 23836 UTC-5 1901 Meadowville Technology Pkwy
192 144 193000000000000191 193000000000100191 RMN3 RMN3 US VA Fredericksburg 22406 UTC-5 220 Centreport Parkway 84
193 248 193000000000000192 193000000000100192 RNO1 RNO1 US NV Fernley 89408 UTC-8 1600 East Newlands RD
194 245 193000000000000193 193000000000100193 RNO2 RNO2 US NV Reno 85906 UTC-8 8000 North Virginia Street
195 244 193000000000000194 193000000000100194 RNO4 RNO4 US NV Reno 89506 UTC-8 8000 North Virginia Street
196 73 193000000000000195 193000000000100195 RYY2 RYY2 US GA White 30184 UTC-5 1250 Cassville White Road, 94
197 218 193000000000000196 193000000000100196 SAT1 SAT1 US TX Schertz 78154-1461 UTC-6 6000 Enterprise Avenue 23
198 149 193000000000000197 193000000000100197 SAT4 SAT4 US TX SAN ANTONIO 78245 UTC-6 10384 W US HIGHWAY 90 61
199 15 193000000000000198 193000000000100198 SAT6 SAT6 US TX SAN MARCOS 78666 UTC-6 1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States 183
200 98 193000000000000199 193000000000100199 SAV3 SAV3 US GA Macon 31216-6427 UTC-5 7001 Skipper Rd 124
201 185 193000000000000200 193000000000100200 SBD1 SBD1 US CA BLOOMINGTON 92316-3819 UTC-8 3388 S Cactus Ave 4
202 179 193000000000000201 193000000000100201 SBD2 SBD2 US CA SAN BERNARDINO 92408-2805 UTC-8 1494 S WATERMAN AVE 15
203 97 193000000000000202 193000000000100202 SCK1 SCK1 US CA STOCKTON 95215 UTC-8 4532 NEWCASTLE RD 20
204 60 193000000000000203 193000000000100203 SCK3 SCK3 US CA MANTECA 95336 UTC-8 3565 N AIRPORT WAY MANTECA 54
205 186 193000000000000204 193000000000100204 SCK4 SCK4 US CA STOCKTON 95215-8354 UTC-8 6001 S AUSTIN RD 10
206 104 193000000000000205 193000000000100205 SCK8 SCK8 US CA OAKLEY 94561-2677 UTC-8 4700 WILBUR AVE 141
207 266 193000000000000206 193000000000100206 SDF1 SDF1 US KY Campbellsville 42718 UTC-5 1050 South Columbia AVE
208 265 193000000000000207 193000000000100207 SDF2 SDF2 US KY Watterson Park 40218 UTC-5 4360 Robards Ln
209 264 193000000000000208 193000000000100208 SDF4 SDF4 US KY Shepherdsville 40165 UTC-5 376 Zappos.com Blvd
210 263 193000000000000209 193000000000100209 SDF6 SDF6 US KY Shepherdsville 40165 UTC-5 271 Omega Pkwy
211 258 193000000000000210 193000000000100210 SDF7 SDF7 US KY Shepherdsville 40165 UTC-5 300 Omicron Court
212 273 193000000000000211 193000000000100211 SDF8 SDF8 US IN Jeffersonville 47130 UTC-5 900 Patrol Rd
213 257 193000000000000212 193000000000100212 SDF9 SDF9 US KY Shepherdsville 40165 UTC-5 100 W. Thomas P. Echols Lane
214 205 193000000000000213 193000000000100213 SEA6 SEA6 US WA South Seattle 98144 UTC-8 2646 Rainier Ave.
215 204 193000000000000214 193000000000100214 SEA8 SEA8 US WA Northeast Bellevue 98005 UTC-8 1475 124th Avenue
216 295 193000000000000215 193000000000100215 SJC7 SJC7 US CA Tracy 95377-8906 UTC-8 188 Mountain House Parkway 30
217 153 193000000000000216 193000000000100216 SLC2 SLC2 US UT West Jordan 84081 UTC-7 7148 W. Old Bingham Hwy 86
218 13 193000000000000217 193000000000100217 SLC3 SLC3 US UT SALT LAKE CITY 84116-4413 UTC-7 355 N JOHN GLENN RD 185
219 184 193000000000000218 193000000000100218 SMF3 SMF3 US CA Stockton 95206-8202 UTC-8 3923 S B ST 9
220 105 193000000000000219 193000000000100219 SMF6 SMF6 US CA SACRAMENTO 95837-9109 UTC-8 4930 ALLBAUGH DR 28
221 296 193000000000000220 193000000000100220 SNA4 SNA4 US CA Rialto 92376-3009 UTC-8 2496 W Walnut Ave 18
222 109 193000000000000221 193000000000100221 STL3 STL3 US MO REPUBLIC 65738-2779 UTC-6 3200 E SAWYER RD 152
223 194 193000000000000222 193000000000100222 STL4 STL4 US IL Edwardsville 62025-2815 UTC-6 3050 GATEWAY COMMERCE CENTER DR S 115
224 122 193000000000000223 193000000000100223 SWF1 SWF1 US NY ROCK TAVERN 12575 UTC-5 635 INTERNATIONAL BLVD 134
225 76 193000000000000224 193000000000100224 SWF2 SWF2 US NY Hopewell Junction 12533 UTC-5 76 Patriot Way Hopewell Junction 62
226 41 193000000000000225 193000000000100225 TCY1 TCY1 US CA STOCKTON 95206 UTC-8 2690 East Arch Airport Road 139
227 43 193000000000000226 193000000000100226 TCY2 TCY2 US CA STOCKTON 95215 UTC-8 6201 Newcastle Rd 138
228 250 193000000000000227 193000000000100227 TEB3 TEB3 US NJ Logan Township 08085 UTC-5 2651 Oldmans Creek Rd 47
229 130 193000000000000228 193000000000100228 TEB4 TEB4 US NJ PENNS GROVE 08069 UTC-5 747 COURSES LANDING RD 55
230 249 193000000000000229 193000000000100229 TEB6 TEB6 US NJ Cranbury 08512 UTC-5 22 Hightstown Cranbury State Road 114
231 156 193000000000000230 193000000000100230 TEB9 TEB9 US NJ Somerset 08873 UTC-5 601 Randolph Road 40
232 65 193000000000000231 193000000000100231 TMB8 TMB8 US FL HOMESTEAD 33032-8597 UTC-5 27505 SW 132nd AVE 116
233 18 193000000000000232 193000000000100232 TOL3 TOL3 US OH PERRYSBURG 43551 UTC-5 9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States 173
234 284 193000000000000233 193000000000100233 TPA1 TPA1 US FL Ruskin 33570 UTC-5 3350 Laurel Ridge Ave.
235 283 193000000000000234 193000000000100234 TPA2 TPA2 US FL Lakeland 33811 UTC-5 1760 County Line Rd 22
236 124 193000000000000235 193000000000100235 TPA3 TPA3 US FL Auburndale 33823 UTC-5 676 C. Fred Jones Boulevard 146
237 272 193000000000000236 193000000000100236 TUL1 TUL1 US KS Coffeyville 67337 UTC-6 2654 North Highway 169
238 172 193000000000000237 193000000000100237 VGT2 VGT2 US NV LAS VEGAS 89115-2013 UTC-8 6401 Howdy Wells Ave 6
239 228 193000000000000238 193000000000100238 VUBA VUBA US PA Scranton 18512 UTC-5 1000 Keystone Industrial Park
240 227 193000000000000239 193000000000100239 VUGA VUGA US PA Pittston 18643 UTC-5 508 Delaware Avenue West
241 82 193000000000000240 193000000000100240 WBW2 WBW2 US PA OLYPHANT 18447 UTC-5 1300 Corporate Way 119
242 36 193000000000000241 193000000000100241 XAV3 XAV3 US GA PORT WENTWORTH 31407 UTC-5 XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States 180
243 37 193000000000000242 193000000000100242 XEW5 XEW5 US NJ Cinnaminson 08077-2015 UTC-5 995 Taylors Ln 08077-2015 163
244 25 193000000000000243 193000000000100243 XIN5 XIN5 US IN Avon 46123-1700 UTC-5 8838 E COUNTY ROAD 100 S 182
245 34 193000000000000244 193000000000100244 XLX1 XLX1 US MD HAGERSTOWN 21740 UTC-5 1115 Wesel Blvd HAGERSTOWN,MD 21740,USA 160
246 23 193000000000000245 193000000000100245 XLX6 XLX6 US NC PINEVILLE 28134 UTC-5 12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA 166
247 63 193000000000000246 193000000000100246 XLX7 XLX7 US CA VICTORVILLE 92394 UTC-8 18580 Gateway Dr,Victorville,CA 92394,USA 27
248 26 193000000000000247 193000000000100247 XPB2 XPB2 US FL APOPKA 32712 UTC-5 XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States 165
249 24 193000000000000248 193000000000100248 XRI3 XRI3 US VA Richmond 23234 UTC-5 4949 Commerce Rd, Richmond, VA 23234 167
250 213 193000000000000249 193000000000100249 XUSB XUSB US TX Fort Worth 76155 UTC-6 14900 Frye Rd.
251 226 193000000000000250 193000000000100250 XUSC XUSC US PA Carlisle 17013 UTC-5 40 Logistics Drive
252 297 193000000000000251 193000000000100251 XUSD XUSD US CA Stockton 95206 UTC-8 1909 Zephyr St
253 280 193000000000000252 193000000000100252 XUSE XUSE US IN Whitestown 46075 UTC-5 5100 S Indianapolis Rd
@@ -0,0 +1,55 @@
source_row,warehouse_code,reject_reason,country_code,state_province,city,postal_code,timezone,address_line1,source_internal_id
29,纽约仓一件代发,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"Door13-14,75 Carter Dr Edison,",NJDF
30,纽约仓,invalid_fba_code|non_fba_business_node,US,NJ,New Jersey,08817,UTC-5,"13、14号门75 Carter Dr Edison,",
42,海外仓打托,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,91730,,91730,13
47,FEDEX,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,12
48,UPS,missing_city|missing_state|non_fba_business_node,US,,,92337,,10767,11
50,JAX3,missing_city|missing_state,US,,,32210,,JAX3-32210,111
51,私人仓,missing_city|missing_state|invalid_fba_code|non_fba_business_node,US,,,私人仓,,私人仓,14
52,洛杉矶仓一件代发,invalid_fba_code|non_fba_business_node,US,CA,RANCHO CUCAMONGA,91730,UTC-8,10767 7TH STREET,CADF
53,XBN3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,91
54,XMI3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,89
55,XCH2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,87
56,CEG2,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,79
57,HEA2,missing_city|missing_state,US,,,18031,,"650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States",77
58,STL6,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,64
59,HOU7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,63
61,SMF7,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,53
62,IAK3,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,44
64,AZA4,missing_city|missing_state|missing_address|missing_postal_code,US,,,,,,16
66,TMB8-UPS,invalid_fba_code|non_fba_business_node,US,FL,Miami Gardens,33055,UTC-5,8675309 Jenny Lane,
79,NJ3-WALMART,invalid_fba_code|non_fba_business_node,US,NJ,Pedricktown,08067,UTC-5,3 Sorbello Way,
83,IUSP-AWD,invalid_fba_code,US,CA,HESPERIA,92344,UTC-8,8140 Caliente Rd,83
85,LBE1-UPS,invalid_fba_code|non_fba_business_node,US,PA,New Stanton,15672,UTC-5,165 GLENN FOX RD,
90,PHL5S-WALMART,invalid_fba_code|non_fba_business_node,US,PA,Greencastle,17225,UTC-5,1915 Ebberts Spg Ct,
91,ORD1S-WALMART,invalid_fba_code|non_fba_business_node,US,IL,Joliet,60436,UTC-6,3501 Brandon Road,
92,PSC2-UPS,invalid_fba_code|non_fba_business_node,US,OR,Portland,97242,UTC-8,9930 NE Alderwood Rd,
96,ABQ2-UPS,invalid_fba_code|non_fba_business_node,US,AZ,GOODYEAR,85395,UTC-7,16601 W. SELLS DRIVE,
101,LAN2-48917,invalid_fba_code,US,MI,LANSING,48917,UTC-5,6500 W Mt Hope Hwy,
108,MCO1-WALMART,invalid_fba_code|non_fba_business_node,US,FL,Davenport,33897,UTC-5,5100 NORTH RIDGE TRAIL,
111,MDW2-60433-3280,invalid_fba_code,US,IL,Joliet,60433-3280,UTC-6,250 Emerald Drive,
112,VGT2-FEDEX,invalid_fba_code|non_fba_business_node,US,NV,LAS VEGAS,89115-201,UTC-8,6401 Howdy Wells Ave,
116,ORF2-UPS,invalid_fba_code|non_fba_business_node,US,VA,Richmond,23297,UTC-5,9603 Coach Road,
137,SWF2-10710,invalid_fba_code,US,NY,Yonkers,10710,UTC-5,557 Tuckahoe Road,
138,ATL1-WALMART,invalid_fba_code|non_fba_business_node,US,GA,Atlanta,30349,UTC-5,"6055 S Fulton Pkwy,",
139,IND3-亚马逊,invalid_fba_code|timezone_pending_confirmation,US,IN,Pkwy Plainfield,46168,,"717 Airtech Pkwy Plainfield IN 46168,USA",
159,ORF2-FEDEX,invalid_fba_code|non_fba_business_node,US,VA,CHESAPEAKE,23321-1425,UTC-5,ORF2 - 5045 PORTSMOUTH BLVD,
160,SWF2-12533,invalid_fba_code,US,NY,Hopewell Junction,12533-6159,UTC-5,76 Patriot Way,
165,SMF1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Sacramento,95837,UTC-8,7000 Powerline Rd,
166,MIA1-33054,invalid_fba_code,US,FL,Opa-Locka,33054,UTC-5,14000 NW 37th Avenue,
167,JAX3-32221,invalid_fba_code,US,FL,Jacksonville,32221,UTC-5,13333 103rd Street,
168,IND1-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9590 AllPoints Pkwy,
169,IND2-WALMART,invalid_fba_code|non_fba_business_node,US,IN,Avon,46123,UTC-5,9360 AllPoints Pkwy,
170,VGT2-UPS,invalid_fba_code|non_fba_business_node,US,CA,March Air Reserve Base,92518,UTC-8,20701 Krameria Ave,
175,MEM1S-WALMART,invalid_fba_code|non_fba_business_node,US,MS,OLIVE BRANCH,38654,UTC-6,9200 Alexander Road,
177,LAX1-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6750 Kimball Avenue,
180,LAX2T-WALMART,invalid_fba_code|non_fba_business_node,US,CA,Chino,91708,UTC-8,6720 Kimball Ave,
239,CMH1-11999,invalid_fba_code,US,OH,Etna,43062-7793,UTC-5,11999 National Road SW,
251,ABE8-309,invalid_fba_code,US,NJ,Florence,08518-2802,UTC-5,309 Cedar Lane,
252,ABE8-401,invalid_fba_code,US,NJ,Florence,08518,UTC-5,401 Independence Road,
277,IND3-沃尔玛,invalid_fba_code,US,IN,Mccordsville,46055,UTC-5,"5756 Enterprise Dr, Mccordsville, IN 46055",
278,IND2-AMAZON,invalid_fba_code,US,IN,Plainfield,46168,UTC-5,715 Airtech Pkwy,
279,IND1-AMAZON,invalid_fba_code,US,IN,Whitestown,46075,UTC-5,4255 Anson Blvd,
281,JAX3-32210,invalid_fba_code,US,FL,Jacksonville,32210-8686,UTC-5,13333 103rd Street,111
282,MIA1-33182,invalid_fba_code,US,FL,Miami,33182,UTC-5,1900 NW 132nd Place Doral,
290,SMF1-AMAZON,invalid_fba_code,US,CA,Sacramento,95835-9505,UTC-8,4900 W Elkhorn Blvd,
1 source_row warehouse_code reject_reason country_code state_province city postal_code timezone address_line1 source_internal_id
2 29 纽约仓一件代发 invalid_fba_code|non_fba_business_node US NJ New Jersey 08817 UTC-5 Door13-14,75 Carter Dr Edison, NJDF
3 30 纽约仓 invalid_fba_code|non_fba_business_node US NJ New Jersey 08817 UTC-5 13、14号门75 Carter Dr Edison,
4 42 海外仓打托 missing_city|missing_state|invalid_fba_code|non_fba_business_node US 91730 91730 13
5 47 FEDEX missing_city|missing_state|non_fba_business_node US 92337 10767 12
6 48 UPS missing_city|missing_state|non_fba_business_node US 92337 10767 11
7 50 JAX3 missing_city|missing_state US 32210 JAX3-32210 111
8 51 私人仓 missing_city|missing_state|invalid_fba_code|non_fba_business_node US 私人仓 私人仓 14
9 52 洛杉矶仓一件代发 invalid_fba_code|non_fba_business_node US CA RANCHO CUCAMONGA 91730 UTC-8 10767 7TH STREET CADF
10 53 XBN3 missing_city|missing_state|missing_address|missing_postal_code US 91
11 54 XMI3 missing_city|missing_state|missing_address|missing_postal_code US 89
12 55 XCH2 missing_city|missing_state|missing_address|missing_postal_code US 87
13 56 CEG2 missing_city|missing_state|missing_address|missing_postal_code US 79
14 57 HEA2 missing_city|missing_state US 18031 650 BOULDER DR Suite A 18031-1536 - BREINIGSVILLE, PA - United States 77
15 58 STL6 missing_city|missing_state|missing_address|missing_postal_code US 64
16 59 HOU7 missing_city|missing_state|missing_address|missing_postal_code US 63
17 61 SMF7 missing_city|missing_state|missing_address|missing_postal_code US 53
18 62 IAK3 missing_city|missing_state|missing_address|missing_postal_code US 44
19 64 AZA4 missing_city|missing_state|missing_address|missing_postal_code US 16
20 66 TMB8-UPS invalid_fba_code|non_fba_business_node US FL Miami Gardens 33055 UTC-5 8675309 Jenny Lane
21 79 NJ3-WALMART invalid_fba_code|non_fba_business_node US NJ Pedricktown 08067 UTC-5 3 Sorbello Way
22 83 IUSP-AWD invalid_fba_code US CA HESPERIA 92344 UTC-8 8140 Caliente Rd 83
23 85 LBE1-UPS invalid_fba_code|non_fba_business_node US PA New Stanton 15672 UTC-5 165 GLENN FOX RD
24 90 PHL5S-WALMART invalid_fba_code|non_fba_business_node US PA Greencastle 17225 UTC-5 1915 Ebberts Spg Ct
25 91 ORD1S-WALMART invalid_fba_code|non_fba_business_node US IL Joliet 60436 UTC-6 3501 Brandon Road
26 92 PSC2-UPS invalid_fba_code|non_fba_business_node US OR Portland 97242 UTC-8 9930 NE Alderwood Rd
27 96 ABQ2-UPS invalid_fba_code|non_fba_business_node US AZ GOODYEAR 85395 UTC-7 16601 W. SELLS DRIVE
28 101 LAN2-48917 invalid_fba_code US MI LANSING 48917 UTC-5 6500 W Mt Hope Hwy
29 108 MCO1-WALMART invalid_fba_code|non_fba_business_node US FL Davenport 33897 UTC-5 5100 NORTH RIDGE TRAIL
30 111 MDW2-60433-3280 invalid_fba_code US IL Joliet 60433-3280 UTC-6 250 Emerald Drive
31 112 VGT2-FEDEX invalid_fba_code|non_fba_business_node US NV LAS VEGAS 89115-201 UTC-8 6401 Howdy Wells Ave
32 116 ORF2-UPS invalid_fba_code|non_fba_business_node US VA Richmond 23297 UTC-5 9603 Coach Road
33 137 SWF2-10710 invalid_fba_code US NY Yonkers 10710 UTC-5 557 Tuckahoe Road
34 138 ATL1-WALMART invalid_fba_code|non_fba_business_node US GA Atlanta 30349 UTC-5 6055 S Fulton Pkwy,
35 139 IND3-亚马逊 invalid_fba_code|timezone_pending_confirmation US IN Pkwy Plainfield 46168 717 Airtech Pkwy Plainfield IN 46168,USA
36 159 ORF2-FEDEX invalid_fba_code|non_fba_business_node US VA CHESAPEAKE 23321-1425 UTC-5 ORF2 - 5045 PORTSMOUTH BLVD
37 160 SWF2-12533 invalid_fba_code US NY Hopewell Junction 12533-6159 UTC-5 76 Patriot Way
38 165 SMF1-WALMART invalid_fba_code|non_fba_business_node US CA Sacramento 95837 UTC-8 7000 Powerline Rd
39 166 MIA1-33054 invalid_fba_code US FL Opa-Locka 33054 UTC-5 14000 NW 37th Avenue
40 167 JAX3-32221 invalid_fba_code US FL Jacksonville 32221 UTC-5 13333 103rd Street
41 168 IND1-WALMART invalid_fba_code|non_fba_business_node US IN Avon 46123 UTC-5 9590 AllPoints Pkwy
42 169 IND2-WALMART invalid_fba_code|non_fba_business_node US IN Avon 46123 UTC-5 9360 AllPoints Pkwy
43 170 VGT2-UPS invalid_fba_code|non_fba_business_node US CA March Air Reserve Base 92518 UTC-8 20701 Krameria Ave
44 175 MEM1S-WALMART invalid_fba_code|non_fba_business_node US MS OLIVE BRANCH 38654 UTC-6 9200 Alexander Road
45 177 LAX1-WALMART invalid_fba_code|non_fba_business_node US CA Chino 91708 UTC-8 6750 Kimball Avenue
46 180 LAX2T-WALMART invalid_fba_code|non_fba_business_node US CA Chino 91708 UTC-8 6720 Kimball Ave
47 239 CMH1-11999 invalid_fba_code US OH Etna 43062-7793 UTC-5 11999 National Road SW
48 251 ABE8-309 invalid_fba_code US NJ Florence 08518-2802 UTC-5 309 Cedar Lane
49 252 ABE8-401 invalid_fba_code US NJ Florence 08518 UTC-5 401 Independence Road
50 277 IND3-沃尔玛 invalid_fba_code US IN Mccordsville 46055 UTC-5 5756 Enterprise Dr, Mccordsville, IN 46055
51 278 IND2-AMAZON invalid_fba_code US IN Plainfield 46168 UTC-5 715 Airtech Pkwy
52 279 IND1-AMAZON invalid_fba_code US IN Whitestown 46075 UTC-5 4255 Anson Blvd
53 281 JAX3-32210 invalid_fba_code US FL Jacksonville 32210-8686 UTC-5 13333 103rd Street 111
54 282 MIA1-33182 invalid_fba_code US FL Miami 33182 UTC-5 1900 NW 132nd Place Doral
55 290 SMF1-AMAZON invalid_fba_code US CA Sacramento 95835-9505 UTC-8 4900 W Elkhorn Blvd
@@ -0,0 +1,5 @@
warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1,note
193000000000200001,193000000000300001,CN-YIWU-01,义乌仓,DOMESTIC,CN,UTC+8,浙江省,义乌市,322000,义乌仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。
193000000000200002,193000000000300002,CN-DONGGUAN-01,东莞仓,DOMESTIC,CN,UTC+8,广东省,东莞市,523000,东莞仓通用地址待维护,按用户确认生成通用格式;后续运营可维护真实地址。
193000000000200003,193000000000300003,US-LAX-01,洛杉矶仓,OVERSEAS,US,UTC-8,CA,Los Angeles,90001,Los Angeles warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。
193000000000200004,193000000000300004,US-NYC-01,纽约仓,OVERSEAS,US,UTC-5,NY,New York,10001,New York warehouse generic address pending maintenance,按用户确认生成通用格式;后续运营可维护真实地址。
1 warehouse_id_seed address_id_seed warehouse_code warehouse_name warehouse_type country_code timezone state_province city postal_code address_line1 note
2 193000000000200001 193000000000300001 CN-YIWU-01 义乌仓 DOMESTIC CN UTC+8 浙江省 义乌市 322000 义乌仓通用地址待维护 按用户确认生成通用格式;后续运营可维护真实地址。
3 193000000000200002 193000000000300002 CN-DONGGUAN-01 东莞仓 DOMESTIC CN UTC+8 广东省 东莞市 523000 东莞仓通用地址待维护 按用户确认生成通用格式;后续运营可维护真实地址。
4 193000000000200003 193000000000300003 US-LAX-01 洛杉矶仓 OVERSEAS US UTC-8 CA Los Angeles 90001 Los Angeles warehouse generic address pending maintenance 按用户确认生成通用格式;后续运营可维护真实地址。
5 193000000000200004 193000000000300004 US-NYC-01 纽约仓 OVERSEAS US UTC-5 NY New York 10001 New York warehouse generic address pending maintenance 按用户确认生成通用格式;后续运营可维护真实地址。
@@ -36,7 +36,7 @@ DELIMITER ;
-- 早期 wh_warehouse 缺少的业务与审计字段。 -- 早期 wh_warehouse 缺少的业务与审计字段。
CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name');
CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type');
CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''UTC+8'' COMMENT ''UTC偏移量'' AFTER country_code');
CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone');
CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name');
CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone');
@@ -0,0 +1,45 @@
# 平台公共仓库主数据
## 范围
- 正式脚本:`warehouse-public-master-data.sql`。
- 清洗通过清单:`master-data/fba-public-addresses.accepted.csv`。
- 拒绝清单:`master-data/fba-public-addresses.rejected.csv`。
- 四个固定仓清单:`master-data/platform-fixed-warehouses.csv`。
本脚本写平台公共作用域 `tenant_id=0` 的四个固定作业仓和 FBA 地址主数据,不写演示仓,不写 Admin 代码。当前内容已内联到 `deploy/sql/头程数据库统一增量-MySQL8.sql`,测试环境应执行固定入口写入正式主数据。
## 清洗规则
- 读取 `C:\Users\BY-OY\Downloads\FBA地址表.xls`,原始文件只读。
- 对核心字段去首尾空格。
- FBA 编码转大写,接受 `^[A-Z0-9]{3,5}$`。
- 邮编按文本保存,保留 `31216-6427` 这类后缀。
- 城市、州、详细地址、邮编任一缺失则拒绝。
- 带 `UPS`、`FEDEX`、`WALMART`、`一件代发`、`私人仓`、`纽约仓`、`洛杉矶仓`、`海外仓打托` 等非 FBA 业务节点语义的记录拒绝。
- 多时区州必须按城市映射时区;无法可靠判断时列入拒绝清单的 `timezone_pending_confirmation`,不使用州级默认值。
- 数据库存储固定 UTC 偏移量(如 `UTC+8`、`UTC-8`),当前仅作为维护和展示字段使用。
## 固定仓规则
- 国内固定仓:义乌、东莞,时区固定 `UTC+8`。
- 海外固定仓:洛杉矶、纽约,分别使用 `UTC-8`、`UTC-5`。
- 地址按通用占位格式写入,后续运营维护真实地址后,重复执行脚本不会把地址覆盖回占位文本。
- 固定仓本轮不批量创建 `wh_service_capability`,避免在真实作业规则确认前扩大业务可操作范围。
## 写入语义
- 新环境使用稳定 ID 区间:FBA 仓库 `193000000000000001+`、FBA 地址 `193000000000100001+`,固定仓库 `193000000000200001+`、固定仓地址 `193000000000300001+`。
- 已有环境同编码仓库存在时,通过 `tenant_id=0 + warehouse_code + deleted=0` 解析实际 `warehouse_id` 后写地址。
- 重复执行不会把停用仓库重新启用。
- 重复执行不会覆盖运营维护的联系人、电话、邮箱、备注。
- FBA 地址不创建 `RECEIVING`、`STORAGE`、`PICK_PACK`、`RETURN` 等平台作业能力。
- 如果稳定种子 ID 已被其他数据占用,或同编码存在已删除仓库,脚本会 `SIGNAL` 终止,交由人工确认。
## 当前结果
- Excel 数据行:306。
- 接受:252。
- 拒绝:54。
- 时区待确认:1,且该行同时存在无效编码和异常城市文本。
- 固定仓:4,分别为义乌、东莞、洛杉矶、纽约。
@@ -0,0 +1,663 @@
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 正式平台公共仓库主数据:固定作业仓 + FBA 地址库。
-- 来源:C:\Users\BY-OY\Downloads\FBA地址表.xls,只读清洗后生成。
-- 接受 252 条,拒绝 54 条;拒绝清单见 master-data/fba-public-addresses.rejected.csv。
-- 四个固定作业仓按通用占位地址写入;清单见 master-data/platform-fixed-warehouses.csv。
-- FBA 是目的地址主数据,不创建 RECEIVING、STORAGE、PICK_PACK、RETURN 等平台作业能力。
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse;
CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse (
warehouse_id_seed BIGINT UNSIGNED NOT NULL,
address_id_seed BIGINT UNSIGNED NOT NULL,
warehouse_code VARCHAR(50) NOT NULL,
warehouse_name VARCHAR(120) NOT NULL,
warehouse_type VARCHAR(32) NOT NULL,
country_code CHAR(2) NOT NULL,
timezone VARCHAR(64) NOT NULL,
state_province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
address_line1 VARCHAR(255) NOT NULL,
PRIMARY KEY (warehouse_code),
UNIQUE KEY uk_tmp_fixed_warehouse_id_seed (warehouse_id_seed),
UNIQUE KEY uk_tmp_fixed_address_id_seed (address_id_seed)
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tmp_wh_fixed_platform_warehouse
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,state_province,city,postal_code,address_line1)
VALUES
(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'),
(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'),
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'),
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance');
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
DELIMITER //
CREATE PROCEDURE assert_wh_fixed_platform_warehouse_seed()
BEGIN
IF (SELECT COUNT(*) FROM tmp_wh_fixed_platform_warehouse) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓源数量不是4';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fixed_platform_warehouse
WHERE warehouse_code <> UPPER(TRIM(warehouse_code))
OR warehouse_type NOT IN ('DOMESTIC', 'OVERSEAS')
OR country_code NOT IN ('CN', 'US')
OR TRIM(timezone) = ''
OR TRIM(city) = ''
OR TRIM(state_province) = ''
OR TRIM(address_line1) = ''
OR TRIM(postal_code) = ''
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓主数据存在未清洗或必填缺失记录';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.id = source.warehouse_id_seed
AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓仓库种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse_address existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.id = source.address_id_seed
LEFT JOIN wh_warehouse parent
ON parent.id = existing.warehouse_id
AND parent.tenant_id = existing.tenant_id
WHERE existing.tenant_id <> 0
OR existing.deleted <> 0
OR parent.warehouse_code IS NULL
OR parent.warehouse_code <> source.warehouse_code
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fixed_platform_warehouse source
ON existing.tenant_id = 0
AND existing.warehouse_code = source.warehouse_code
AND existing.deleted = 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓存在同编码已删除仓库,需人工确认后恢复或清理';
END IF;
END//
DELIMITER ;
CALL assert_wh_fixed_platform_warehouse_seed();
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
INSERT INTO wh_warehouse (
id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone,
contact_name, contact_phone, contact_email, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.warehouse_id_seed,
0,
source.warehouse_code,
source.warehouse_name,
source.warehouse_type,
source.country_code,
source.timezone,
NULL,
NULL,
NULL,
'ACTIVE',
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fixed_platform_warehouse source
ON DUPLICATE KEY UPDATE
warehouse_name = VALUES(warehouse_name),
warehouse_type = VALUES(warehouse_type),
country_code = VALUES(country_code),
timezone = VALUES(timezone),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO wh_warehouse_address (
id, tenant_id, warehouse_id, address_type, country_code, state_province, city,
district, postal_code, address_line1, address_line2, latitude, longitude,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.address_id_seed,
0,
warehouse.id,
'OPERATING',
source.country_code,
source.state_province,
source.city,
NULL,
source.postal_code,
source.address_line1,
NULL,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fixed_platform_warehouse source
JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
ON DUPLICATE KEY UPDATE
tenant_id = VALUES(tenant_id);
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
warehouse_id_seed BIGINT UNSIGNED NOT NULL,
address_id_seed BIGINT UNSIGNED NOT NULL,
warehouse_code VARCHAR(50) NOT NULL,
warehouse_name VARCHAR(120) NOT NULL,
state_province VARCHAR(100) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
timezone VARCHAR(64) NOT NULL,
address_line1 VARCHAR(255) NOT NULL,
source_row INT NOT NULL,
PRIMARY KEY (warehouse_code),
UNIQUE KEY uk_tmp_fba_warehouse_id_seed (warehouse_id_seed),
UNIQUE KEY uk_tmp_fba_address_id_seed (address_id_seed)
) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO tmp_wh_fba_public_master_data
(warehouse_id_seed,address_id_seed,warehouse_code,warehouse_name,state_province,city,postal_code,timezone,address_line1,source_row)
VALUES
(193000000000000001,193000000000100001,'ABE2','ABE2','PA','Breinigsville','18031','UTC-5','705 Boulder Drive',238),
(193000000000000002,193000000000100002,'ABE3','ABE3','PA','Breinigsville','18031','UTC-5','650 Boulder Drive',237),
(193000000000000003,193000000000100003,'ABE4','ABE4','PA','Easton','18045','UTC-5','1610 Van Buren Road',134),
(193000000000000004,193000000000100004,'ABE5','ABE5','PA','Harrisburg','17112','UTC-5','6455 Allentown Boulevard',231),
(193000000000000005,193000000000100005,'ABE8','ABE8','NJ','Florence','08518','UTC-5','401 Independence Road 08518-2200',125),
(193000000000000006,193000000000100006,'ABQ2','ABQ2','NM','LOS LUNAS','87031','UTC-7','6251 Pioneer Trail NW',99),
(193000000000000007,193000000000100007,'ACY2','ACY2','NJ','BURLINGTON','08016','UTC-5','1101 E PEARL ST',84),
(193000000000000008,193000000000100008,'AFW1','AFW1','TX','FORT WORTH','76131','UTC-6','1851 NE LOOP 820',141),
(193000000000000009,193000000000100009,'AFW5','AFW5','TX','BALCH SPRINGS','75180-1636','UTC-6','12374 LAKE JUNE RD',27),
(193000000000000010,193000000000100010,'AKR1','AKR1','OH','Canton','44705','UTC-5','4747 Rebar Ave NE',148),
(193000000000000011,193000000000100011,'ALB1','ALB1','NY','Castleton','12033-9634','UTC-5','1835 US Route 9',174),
(193000000000000012,193000000000100012,'AMA1','AMA1','TX','Amarillo','79108','UTC-6','8590 NE 24th Avenue',129),
(193000000000000013,193000000000100013,'ATL2N','ATL2N','GA','Carrollton','30117','UTC-5','3101 N. Highway 27',31),
(193000000000000014,193000000000100014,'ATL3','ATL3','GA','Pendergrass','30567','UTC-5','445 Valentine Industrial Parkway',147),
(193000000000000015,193000000000100015,'ATL6','ATL6','GA','East Point','30344','UTC-5','4200 North Commerce',202),
(193000000000000016,193000000000100016,'AVP1','AVP1','PA','Hazle Township','18202-9361','UTC-5','550 Oak Ridge Road',233),
(193000000000000017,193000000000100017,'AVP3','AVP3','PA','GOULDSBORO','18424-9492','UTC-5','298 1ST AVE',232),
(193000000000000018,193000000000100018,'BDL1','BDL1','CT','Windsor','06095','UTC-5','801 Day Hill Rd.',289),
(193000000000000019,193000000000100019,'BFI1','BFI1','WA','Sumner','98390','UTC-8','1800 140th Avenue E.',208),
(193000000000000020,193000000000100020,'BFI3','BFI3','WA','Dupont','98327-9607','UTC-8','2700 Center Drive',207),
(193000000000000021,193000000000100021,'BFI7','BFI7','WA','Sumner','98390','UTC-8','1901 140th Ave E',206),
(193000000000000022,193000000000100022,'BJC1','BJC1','CO','AURORA','80019','UTC-7','6300 N Powhaton Road',11),
(193000000000000023,193000000000100023,'BNA1','BNA1','TN','Lebanon','37090','UTC-6','14840 Central Pike Suite 190',221),
(193000000000000024,193000000000100024,'BNA2','BNA2','TN','Lebanon','37090','UTC-6','500 Duke DR',131),
(193000000000000025,193000000000100025,'BNA3','BNA3','TN','Murfreesboro','37127','UTC-6','2020 Joe B Jackson Pkwy',140),
(193000000000000026,193000000000100026,'BNA4','BNA4','TN','Nashville','37217','UTC-6','50 airways Blvd',220),
(193000000000000027,193000000000100027,'BNA6','BNA6','TN','CLARKSVILLE','37040-5502','UTC-6','3875 GUTHRIE HWY',110),
(193000000000000028,193000000000100028,'BOS1','BOS1','NH','Nashua','03063','UTC-5','10 State St.',243),
(193000000000000029,193000000000100029,'BOS2','BOS2','MA','Drive Stoughton','02072','UTC-5','1000 Tech Centre',193),
(193000000000000030,193000000000100030,'BOS7','BOS7','MA','Fall River','02720','UTC-5','1180 Innovation Way',192),
(193000000000000031,193000000000100031,'BWI1','BWI1','VA','Sterling','20166-2058','UTC-5','45121 Global Plaza',242),
(193000000000000032,193000000000100032,'BWI2','BWI2','MD','Baltimore','21224','UTC-5','2010 Broening Hwy',241),
(193000000000000033,193000000000100033,'BWI4','BWI4','VA','CLEARBROOK','22624','UTC-5','165 Business Blvd',102),
(193000000000000034,193000000000100034,'CAE1','CAE1','SC','West Columbia','29172','UTC-5','4400 12TH STREET EXT',225),
(193000000000000035,193000000000100035,'CHA1','CHA1','TN','Chattanooga','37416','UTC-5','7200 Discovery Drive',223),
(193000000000000036,193000000000100036,'CHA2','CHA2','TN','Charleston','37310','UTC-5','225 Infinity Dr NW',222),
(193000000000000037,193000000000100037,'CHO1','CHO1','VA','FISHERSVILLE','22939','UTC-5','32 TRADER RD',75),
(193000000000000038,193000000000100038,'CLT2','CLT2','NC','Charlotte','28214-8082','UTC-5','10240 Old Dowd Rd',191),
(193000000000000039,193000000000100039,'CLT3','CLT3','NC','CONCORD','28027','UTC-5','6500 Davidson Hwy',114),
(193000000000000040,193000000000100040,'CMH2','CMH2','OH','Groveport','43125-9016','UTC-5','6050 Gateway Court',240),
(193000000000000041,193000000000100041,'CMH3','CMH3','OH','Monroe','45050-1848','UTC-5','700 GATEWAY BLVD',181),
(193000000000000042,193000000000100042,'CVG1','CVG1','KY','Hebron','41048','UTC-5','1155 Worldwide Blvd',262),
(193000000000000043,193000000000100043,'CVG2','CVG2','KY','Hebron','41048','UTC-5','1600 Worldwide Blvd',261),
(193000000000000044,193000000000100044,'CVG3','CVG3','KY','Hebron','41048','UTC-5','3680 Langley Dr.',260),
(193000000000000045,193000000000100045,'CVG5','CVG5','KY','Hebron','41048','UTC-5','2285 Litton Lane',259),
(193000000000000046,193000000000100046,'DCA6','DCA6','MD','BALTIMORE','21219','UTC-5','6001 Bethlehem Blvd',135),
(193000000000000047,193000000000100047,'DEN2','DEN2','CO','Aurora','80019','UTC-7','22205 East 19th Ave',123),
(193000000000000048,193000000000100048,'DEN8','DEN8','CO','Aurora','80018','UTC-7','21000 E 13th Ave',120),
(193000000000000049,193000000000100049,'DET1','DET1','MI','Livonia','48150-1543','UTC-5','39000 Amrhein Road',189),
(193000000000000050,193000000000100050,'DET2','DET2','MI','Shelby Township','48317-1318','UTC-5','50500 Mound Rd',182),
(193000000000000051,193000000000100051,'DFW5S','DFW5S','TX','Lancaster','75146','UTC-6','2500 E Belt Line Rd,',143),
(193000000000000052,193000000000100052,'DFW6','DFW6','TX','Coppell','75019','UTC-6','940 W Bethel Road',219),
(193000000000000053,193000000000100053,'DFW8','DFW8','TX','DFW Airport','75261','UTC-6','2700 Regent Blvd',217),
(193000000000000054,193000000000100054,'EWR4','EWR4','NJ','Robbinsville','08691','UTC-5','50 New Canton Way',256),
(193000000000000055,193000000000100055,'EWR5','EWR5','NJ','Avenel','07001','UTC-5','301 Blair Rd.',255),
(193000000000000056,193000000000100056,'EWR7','EWR7','NJ','Avenel','07001','UTC-5','301 Blair Rd.',254),
(193000000000000057,193000000000100057,'EWR9','EWR9','NJ','Carteret','07008','UTC-5','8003 Industrial Parkway',253),
(193000000000000058,193000000000100058,'FAT2','FAT2','CA','VISALIA','93291-1882','UTC-8','3315 N KELSEYST',117),
(193000000000000059,193000000000100059,'FOE1','FOE1','KS','KANSAS CITY','66109-3665','UTC-6','9400 LEAVENWORTH RD',176),
(193000000000000060,193000000000100060,'FTW1','FTW1','TX','Dallas','75241','UTC-6','33333 Lyndon B Johnson FREEWAY',212),
(193000000000000061,193000000000100061,'FTW2','FTW2','TX','Coppell','75019','UTC-6','2701 West Bethel Road',211),
(193000000000000062,193000000000100062,'FTW3','FTW3','TX','Fort Worth','76177-2517','UTC-6','15201 Heritage Pkwy',89),
(193000000000000063,193000000000100063,'FTW5','FTW5','TX','Forney','75126','UTC-6','1475 AKRON WAY',121),
(193000000000000064,193000000000100064,'FWA4','FWA4','IN','FORT WAYNE','46809-9771','UTC-5','9798 Smith Road',171),
(193000000000000065,193000000000100065,'GEG2','GEG2','WA','SPOKANE','99216','UTC-8','18007 E Garland Ave',151),
(193000000000000066,193000000000100066,'GEU2','GEU2','AZ','Glendale','85307','UTC-7','15301 West Northern Avenue',67),
(193000000000000067,193000000000100067,'GEU3','GEU3','AZ','BUCKEYE','85396','UTC-7','18900 W McDowell Road',78),
(193000000000000068,193000000000100068,'GEU5','GEU5','AZ','GOODYEAR','85395','UTC-7','4660 North Cotton Lane',68),
(193000000000000069,193000000000100069,'GSO1','GSO1','NC','KERNERSVILLE','27284','UTC-5','1656 OLD GREENSBORO RD',164),
(193000000000000070,193000000000100070,'GSP1','GSP1','SC','Spartanburg','29303','UTC-5','402 John Dodd Rd',224),
(193000000000000071,193000000000100071,'GYR2','GYR2','AZ','GOODYEAR','85395-7672','UTC-7','17341 W MINNEZONA AVE',188),
(193000000000000072,193000000000100072,'GYR3','GYR3','AZ','PHOENIX','85043-2356','UTC-7','8181 W ROOSEVELT ST',187),
(193000000000000073,193000000000100073,'HGR6','HGR6','MD','HAGERSTOWN','21740-7301','UTC-5','55 West Oak Ridge Drive',80),
(193000000000000074,193000000000100074,'HIA1','HIA1','PA','MIDDLETOWN','17057','UTC-5','3327 E Harrisburg Pik',87),
(193000000000000075,193000000000100075,'HLI2','HLI2','CA','HOLLISTER','95023','UTC-8','600 Ernie Drive',44),
(193000000000000076,193000000000100076,'HOU1','HOU1','TX','Humble','77338','UTC-6','8120 Humble Westfield Rd',216),
(193000000000000077,193000000000100077,'HOU2','HOU2','TX','Houston','77038-2324','UTC-6','10550 Ella Blvd.',215),
(193000000000000078,193000000000100078,'HOU3','HOU3','TX','Brookshire','77423','UTC-6','31555 Highway 90 E',214),
(193000000000000079,193000000000100079,'HOU8','HOU8','TX','FRESNO','77545','UTC-6','2303 Hurricane Lane',154),
(193000000000000080,193000000000100080,'HSV1','HSV1','AL','Madison','35756','UTC-6','7817 Greenbrier Road',142),
(193000000000000081,193000000000100081,'HSV2','HSV2','AL','MADISON','35756','UTC-6','28869 Fanning Dr NW',126),
(193000000000000082,193000000000100082,'IAH3','IAH3','TX','Houston','77032','UTC-6','15525 Milner Road',155),
(193000000000000083,193000000000100083,'IAZ1','IAZ1','AZ','GOODYEAR','85395-4515','UTC-7','4660 N COTTON LN 85395-4515 - GOODYEAR, AZ - United States',3),
(193000000000000084,193000000000100084,'ICT2','ICT2','KS','PARK CITY','67219','UTC-6','7130 N BROADWAY AVE',136),
(193000000000000085,193000000000100085,'IGA3','IGA3','GA','MACON','31216-6427','UTC-5','7001 Skipper Rd 31216-6427 MACON, GA - United States',2),
(193000000000000086,193000000000100086,'IGQ2','IGQ2','IL','University park','60484','UTC-6','23257 S. Central Avenue',152),
(193000000000000087,193000000000100087,'ILG1','ILG1','DE','NEW CASTLE','19720','UTC-5','780 S. DuPont Highway',119),
(193000000000000088,193000000000100088,'IMO1','IMO1','MO','LIBERTY','64068-8933','UTC-6','2361 S Withers RD',10),
(193000000000000089,193000000000100089,'IMS1','IMS1','MS','OLIVE BRANCH','38654','UTC-6','IMS1-11505 Progress Way,38654-2510,OLIVE BRANCH,MS,US',6),
(193000000000000090,193000000000100090,'INC1','INC1','NC','FAYETTEVILLE','28303-2215','UTC-5','6309 Bragg Blvd 28303-2215 - FAYETTEVILLE, NC - United States',5),
(193000000000000091,193000000000100091,'IND2','IND2','IN','Plainfield','46168-7442','UTC-5','715 Airtech Pkwy',88),
(193000000000000092,193000000000100092,'IND4','IND4','IN','Indianapolis','46231','UTC-5','710 S. Girls School Rd',276),
(193000000000000093,193000000000100093,'IND5','IND5','IN','Plainfield','46168','UTC-5','800 Perry Road',275),
(193000000000000094,193000000000100094,'IND6','IND6','IN','Jeffersonville','47130','UTC-5','1453 E 10th St',274),
(193000000000000095,193000000000100095,'IND9','IND9','IN','GREENWOOD','46143-7830','UTC-5','1151 S GRAHAM RD',173),
(193000000000000096,193000000000100096,'ITX3','ITX3','TX','AMARILLO','79108-7749','UTC-6','ITX3-8590 NE 24th Ave,79108-7749,AMARILLO,TX,US',9),
(193000000000000097,193000000000100097,'IUSF','IUSF','TX','DALLAS','75241','UTC-6','3351 Balmorhea Dr',22),
(193000000000000098,193000000000100098,'IUSJ','IUSJ','CA','BEAUMONT','92223','UTC-8','36900 West 4th Street',69),
(193000000000000099,193000000000100099,'IUSP','IUSP','CA','HESPERIA,','92344','UTC-8','8140 Caliente Rd',74),
(193000000000000100,193000000000100100,'IUSQ','IUSQ','CA','REDLANDS','92374','UTC-8','2125 W San Bernardino Ave',70),
(193000000000000101,193000000000100101,'IUSR','IUSR','SC','COWPENS','29330','UTC-5','1120 Mt Olive Rd',40),
(193000000000000102,193000000000100102,'IUST','IUST','PA','GREENCASTLE','17225','UTC-5','3PL AWD (Greencastle, PA, US) 15651 Greenmount RdGREENCASTLE, PA 17225 United States',39),
(193000000000000103,193000000000100103,'IUSW','IUSW','CA','Hesperia','92344','UTC-8','8130 Caliente Rd 92344 - Hesperia, CA - United States',33),
(193000000000000104,193000000000100104,'IUTE','IUTE','AZ','GOODYEAR','85338','UTC-7','16155 W Elwood St',21),
(193000000000000105,193000000000100105,'IUTJ','IUTJ','CA','Fontana','92335','UTC-8','9253 Dreamland Drive 92335 - Fontana, CA - United',8),
(193000000000000106,193000000000100106,'IUTL','IUTL','CA','Moreno Valley','92551','UTC-8','24405 Krameria Ave 92551 - Moreno Valley, CA - United States',7),
(193000000000000107,193000000000100107,'IWA6','IWA6','WA','PASCO','99301-6161','UTC-8','1202 S Road 40 E 99301-6161 - PASCO, WA - United States',4),
(193000000000000108,193000000000100108,'JOT1','JOT1','CA','SAN BERNARDINO','92408','UTC-8','1494 S WATERMAN AVE',72),
(193000000000000109,193000000000100109,'JVL1','JVL1','WI','BELOIT','53511','UTC-6','1255 Gateway Blvd',150),
(193000000000000110,193000000000100110,'KRB4','KRB4','CA','PERRIS','92571-9722','UTC-8','4501 PATTERSON AVE',178),
(193000000000000111,193000000000100111,'KY1','KY1','KY','Shepherdsville','40165','UTC-5','120 Velocity Way.,',161),
(193000000000000112,193000000000100112,'LAN2','LAN2','MI','LANSING','48917','UTC-5','6500 W Mt Hope Hwy',100),
(193000000000000113,193000000000100113,'LAS1','LAS1','NV','HENDERSON','89044','UTC-8','12300 Bermuda Road',247),
(193000000000000114,193000000000100114,'LAS2','LAS2','NV','Las Vegas','89030','UTC-8','3837 Bay Lake Trail, Suite 111 North',246),
(193000000000000115,193000000000100115,'LAS6','LAS6','NV','Las Vegas','89115','UTC-8','4550 Nexus Way',132),
(193000000000000116,193000000000100116,'LAX2T','LAX2T','CA','CHINO','91708','UTC-8','6730 Kimball Ave',162),
(193000000000000117,193000000000100117,'LAX9','LAX9','CA','FONTANA','92337','UTC-8','11263 Oleander Ave',183),
(193000000000000118,193000000000100118,'LBE1','LBE1','PA','Greensburg','15605','UTC-5','165 Glen Fox Road',86),
(193000000000000119,193000000000100119,'LEX1','LEX1','KY','Lexington','40511','UTC-5','1850 Mercer RD',268),
(193000000000000120,193000000000100120,'LEX2','LEX2','KY','Lexington','40511','UTC-5','172 Trade St.',267),
(193000000000000121,193000000000100121,'LFT1','LFT1','LA','CARENCRO','70520-5976','UTC-6','3550 NE EVANGELINE TRWY',107),
(193000000000000122,193000000000100122,'LGB3','LGB3','CA','Eastvale','91752','UTC-8','4950 Goodman Way',291),
(193000000000000123,193000000000100123,'LGB4','LGB4','CA','Redlands','92374','UTC-8','27517 Pioneer Avenue',292),
(193000000000000124,193000000000100124,'LGB6','LGB6','CA','Riverside','92518-1513','UTC-8','20901 Krameria Ave',293),
(193000000000000125,193000000000100125,'LGB8','LGB8','CA','Rialto','92376','UTC-8','1568 N Linden Ave',294),
(193000000000000126,193000000000100126,'LIT2','LIT2','AR','NORTH LITTLE ROCK','72117','UTC-6','13001 HIGHWAY 70',106),
(193000000000000127,193000000000100127,'MCC1','MCC1','CA','RANCHO CORDOVA','95742','UTC-8','3300 Rancho Cordova Pkwy 95742 - RANCHO CORDOVA, CA - United States',32),
(193000000000000128,193000000000100128,'MCE1','MCE1','CA','TURLOCK','95380','UTC-8','FULKERTH RD',157),
(193000000000000129,193000000000100129,'MCI1','MCI1','KS','Lenexa','66219','UTC-6','113th St & Renner Blvd.',271),
(193000000000000130,193000000000100130,'MCI4','MCI4','MO','KANSAS CITY','64163','UTC-6','MCI4 - 10501 NW 136th Street',12),
(193000000000000131,193000000000100131,'MCO2','MCO2','FL','Deltona','32725-2610','UTC-5','2600 N Normandy Blvd',115),
(193000000000000132,193000000000100132,'MDT1','MDT1','PA','Carlisle','17015','UTC-5','2Ames Drive',230),
(193000000000000133,193000000000100133,'MDT4','MDT4','PA','York','17408','UTC-5','5125 Commerce Drive',128),
(193000000000000134,193000000000100134,'MDW2','MDW2','IL','Joliet','60433-3280','UTC-6','250 Emerald Drive',200),
(193000000000000135,193000000000100135,'MDW6','MDW6','IL','ROMEOVILLE','60446-6529','UTC-6','1125 W REMINGTON BLVD ROMEOVILLE',199),
(193000000000000136,193000000000100136,'MDW7','MDW7','IL','Monee','60449','UTC-6','6605 Monee Manhattan Road',198),
(193000000000000137,193000000000100137,'MDW8','MDW8','IL','Waukegan','60085','UTC-6','1750 Bridge Drive',197),
(193000000000000138,193000000000100138,'MDW9','MDW9','IL','Aurora','60502','UTC-6','2865 Duke Parkway',196),
(193000000000000139,193000000000100139,'MEM1','MEM1','TN','Memphis','38118','UTC-6','3292 E Holmes Rd',163),
(193000000000000140,193000000000100140,'MEM6','MEM6','MS','OLIVE BRANCH','38654','UTC-6','11505 Progress Way',118),
(193000000000000141,193000000000100141,'MGE3','MGE3','GA','Jefferson','30549-2010','UTC-5','808 Hog Mountain Road',201),
(193000000000000142,193000000000100142,'MIT2','MIT2','CA','SHAFTER','93263','UTC-8','5408 Express Avenue',77),
(193000000000000143,193000000000100143,'MKC4','MKC4','KS','Edgerton','66021-9588','UTC-6','19645 Waverly Rd',270),
(193000000000000144,193000000000100144,'MKC6','MKC6','KS','Kansas City','66102','UTC-6','6925 Riverview Avenue',269),
(193000000000000145,193000000000100145,'MKE1','MKE1','WI','Kenosha','53144','UTC-6','3501 120th Ave',203),
(193000000000000146,193000000000100146,'MQJ1','MQJ1','IN','GREENFIELD','46140','UTC-5','4412 W 300 RD N',94),
(193000000000000147,193000000000100147,'MSP1','MSP1','MN','Shakopee','55379','UTC-6','2601 4th Avenue East',190),
(193000000000000148,193000000000100148,'NJ3','NJ3','NJ','Pedricktown','08067','UTC-5','3 Sorbello Way',17),
(193000000000000149,193000000000100149,'OAK3','OAK3','CA','Patterson','95363-8876','UTC-8','255 Park Center Drive',299),
(193000000000000150,193000000000100150,'OAK4','OAK4','CA','Tracy','95304-9370','UTC-8','1555 N. Chrisman Rd',300),
(193000000000000151,193000000000100151,'OKC2','OKC2','OK','OKLAHOMA CITY','73159','UTC-6','8991 S PORTLAND AVENUE',133),
(193000000000000152,193000000000100152,'OLM1','OLM1','WA','OLYMPIA','98516','UTC-8','3300 HOGUM BAY RD NE 98516-3111 - OLYMPIA, WA - United States',19),
(193000000000000153,193000000000100153,'ONT2','ONT2','CA','San Bernardino','92408','UTC-8','1910 E Central Ave',303),
(193000000000000154,193000000000100154,'ONT6','ONT6','CA','Moreno Valley','92551-9561','UTC-8','24208 San Michele Rd',302),
(193000000000000155,193000000000100155,'ONT8','ONT8','CA','Moreno Valley','92551-9534','UTC-8','24300 Nandina Ave',301),
(193000000000000156,193000000000100156,'ONT9','ONT9','CA','Redlands','92374','UTC-8','2125 W. San Bernandino Ave',298),
(193000000000000157,193000000000100157,'ORD2','ORD2','IL','CHANNAHON','60410-8758','UTC-6','23714 W Amoco Rd',103),
(193000000000000158,193000000000100158,'ORD6','ORD6','IL','Wood Dale','60191','UTC-6','1250 N Mittel Blvd',195),
(193000000000000159,193000000000100159,'ORF2','ORF2','VA','CHESAPEAKE','23321-1425','UTC-5','ORF2 - 5045 PORTSMOUTH BLVD',113),
(193000000000000160,193000000000100160,'PBI3','PBI3','FL','Jacksonville','32206','UTC-5','21 Jump Street',81),
(193000000000000161,193000000000100161,'PDX7','PDX7','OR','Salem','97317','UTC-8','4775 Depot Ct SE',127),
(193000000000000162,193000000000100162,'PHL1','PHL1','DE','New Castle','19720','UTC-5','1 Centerpoint Blvd.',288),
(193000000000000163,193000000000100163,'PHL3','PHL3','DE','New Castle','19720','UTC-5','1600 Johnson Way',287),
(193000000000000164,193000000000100164,'PHL4','PHL4','PA','Carlisle','17015','UTC-5','21 Roadway Dr',236),
(193000000000000165,193000000000100165,'PHL5','PHL5','PA','Lewisberry','17339','UTC-5','500 McCarthy Dr',235),
(193000000000000166,193000000000100166,'PHL5S','PHL5S','PA','Greencastle','17225','UTC-5','1915 Ebberts Spg Ct, Greencastle, PA 17225',20),
(193000000000000167,193000000000100167,'PHL6','PHL6','PA','Carlisle','17015','UTC-5','675 Allen Rd.',234),
(193000000000000168,193000000000100168,'PHL7','PHL7','DE','Middletown','19709','UTC-5','560 Merrimac Ave',286),
(193000000000000169,193000000000100169,'PHL8','PHL8','DE','Middletown','19709','UTC-5','727 N Broad St',285),
(193000000000000170,193000000000100170,'PHX3','PHX3','AZ','Phoenix','85043','UTC-7','6835 West Buckeye Road',307),
(193000000000000171,193000000000100171,'PHX5','PHX5','AZ','Goodyear','85338','UTC-7','16920 W Commerce Drive',306),
(193000000000000172,193000000000100172,'PHX6','PHX6','AZ','Phoenix','85043','UTC-7','4750 West Mohave St',305),
(193000000000000173,193000000000100173,'PHX7','PHX7','AZ','Phoenix','85043','UTC-7','800 N 75th Ave',304),
(193000000000000174,193000000000100174,'PIT1','PIT1','PA','Pittsburgh','15205','UTC-5','2250 Roswell Drive',229),
(193000000000000175,193000000000100175,'PIT2','PIT2','PA','IMPERIAL','15126-1002','UTC-5','1200 Westport Rd',158),
(193000000000000176,193000000000100176,'POC1','POC1','CA','FONTANA','92336-1123','UTC-8','4121 LYTLE CREEK RD',46),
(193000000000000177,193000000000100177,'POC2','POC2','CA','ONTARIO','91761','UTC-8','4000 South Hamner Avenue',38),
(193000000000000178,193000000000100178,'POC3','POC3','CA','JURUPA VALLEY','92509','UTC-8','6120 Clinker Drive',45),
(193000000000000179,193000000000100179,'PPO4','PPO4','IN','MERRILLVILLE','46410','UTC-6','9850 Mississippi Street',49),
(193000000000000180,193000000000100180,'PSC2','PSC2','WA','PASCO','99301','UTC-8','1351 S Road 40 E',93),
(193000000000000181,193000000000100181,'PSP3','PSP3','CA','DESERT HOT SPRINGS','92240','UTC-8','64165 19th AVE',28),
(193000000000000182,193000000000100182,'QXY5','QXY5','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',71),
(193000000000000183,193000000000100183,'QXY8','QXY8','CA','FRESNO','93725-1962','UTC-8','3611 S NORTHPOINTE DR',35),
(193000000000000184,193000000000100184,'QZZ7','QZZ7','AZ','MESA','85212-9608','UTC-7','8560 E ELLIOT RD',14),
(193000000000000185,193000000000100185,'RDU2','RDU2','NC','Smithfield','27577','UTC-5','2150 US HWY 70 Business West',146),
(193000000000000186,193000000000100186,'RDU4','RDU4','NC','FAYETTEVILLE','28303','UTC-5','6309 Bragg Blvd',95),
(193000000000000187,193000000000100187,'RFD2','RFD2','IL','Huntley','60142','UTC-6','11500 Freeman Road',145),
(193000000000000188,193000000000100188,'RFD4','RFD4','IL','HUNTLEY','60142','UTC-6','11400 Venture Court 60142-8075 - HUNTLEY, IL - United States',16),
(193000000000000189,193000000000100189,'RIC1','RIC1','VA','Petersburg','23803','UTC-5','5000 Commerce Way',210),
(193000000000000190,193000000000100190,'RIC2','RIC2','VA','Chester','23836','UTC-5','1901 Meadowville Technology Pkwy',209),
(193000000000000191,193000000000100191,'RMN3','RMN3','VA','Fredericksburg','22406','UTC-5','220 Centreport Parkway',144),
(193000000000000192,193000000000100192,'RNO1','RNO1','NV','Fernley','89408','UTC-8','1600 East Newlands RD',248),
(193000000000000193,193000000000100193,'RNO2','RNO2','NV','Reno','85906','UTC-8','8000 North Virginia Street',245),
(193000000000000194,193000000000100194,'RNO4','RNO4','NV','Reno','89506','UTC-8','8000 North Virginia Street',244),
(193000000000000195,193000000000100195,'RYY2','RYY2','GA','White','30184','UTC-5','1250 Cassville White Road,',73),
(193000000000000196,193000000000100196,'SAT1','SAT1','TX','Schertz','78154-1461','UTC-6','6000 Enterprise Avenue',218),
(193000000000000197,193000000000100197,'SAT4','SAT4','TX','SAN ANTONIO','78245','UTC-6','10384 W US HIGHWAY 90',149),
(193000000000000198,193000000000100198,'SAT6','SAT6','TX','SAN MARCOS','78666','UTC-6','1346 Fortuna Road 78666-0001 - SAN MARCOS, TX - United States',15),
(193000000000000199,193000000000100199,'SAV3','SAV3','GA','Macon','31216-6427','UTC-5','7001 Skipper Rd',98),
(193000000000000200,193000000000100200,'SBD1','SBD1','CA','BLOOMINGTON','92316-3819','UTC-8','3388 S Cactus Ave',185),
(193000000000000201,193000000000100201,'SBD2','SBD2','CA','SAN BERNARDINO','92408-2805','UTC-8','1494 S WATERMAN AVE',179),
(193000000000000202,193000000000100202,'SCK1','SCK1','CA','STOCKTON','95215','UTC-8','4532 NEWCASTLE RD',97),
(193000000000000203,193000000000100203,'SCK3','SCK3','CA','MANTECA','95336','UTC-8','3565 N AIRPORT WAY MANTECA',60),
(193000000000000204,193000000000100204,'SCK4','SCK4','CA','STOCKTON','95215-8354','UTC-8','6001 S AUSTIN RD',186),
(193000000000000205,193000000000100205,'SCK8','SCK8','CA','OAKLEY','94561-2677','UTC-8','4700 WILBUR AVE',104),
(193000000000000206,193000000000100206,'SDF1','SDF1','KY','Campbellsville','42718','UTC-5','1050 South Columbia AVE',266),
(193000000000000207,193000000000100207,'SDF2','SDF2','KY','Watterson Park','40218','UTC-5','4360 Robards Ln',265),
(193000000000000208,193000000000100208,'SDF4','SDF4','KY','Shepherdsville','40165','UTC-5','376 Zappos.com Blvd',264),
(193000000000000209,193000000000100209,'SDF6','SDF6','KY','Shepherdsville','40165','UTC-5','271 Omega Pkwy',263),
(193000000000000210,193000000000100210,'SDF7','SDF7','KY','Shepherdsville','40165','UTC-5','300 Omicron Court',258),
(193000000000000211,193000000000100211,'SDF8','SDF8','IN','Jeffersonville','47130','UTC-5','900 Patrol Rd',273),
(193000000000000212,193000000000100212,'SDF9','SDF9','KY','Shepherdsville','40165','UTC-5','100 W. Thomas P. Echols Lane',257),
(193000000000000213,193000000000100213,'SEA6','SEA6','WA','South Seattle','98144','UTC-8','2646 Rainier Ave.',205),
(193000000000000214,193000000000100214,'SEA8','SEA8','WA','Northeast Bellevue','98005','UTC-8','1475 124th Avenue',204),
(193000000000000215,193000000000100215,'SJC7','SJC7','CA','Tracy','95377-8906','UTC-8','188 Mountain House Parkway',295),
(193000000000000216,193000000000100216,'SLC2','SLC2','UT','West Jordan','84081','UTC-7','7148 W. Old Bingham Hwy',153),
(193000000000000217,193000000000100217,'SLC3','SLC3','UT','SALT LAKE CITY','84116-4413','UTC-7','355 N JOHN GLENN RD',13),
(193000000000000218,193000000000100218,'SMF3','SMF3','CA','Stockton','95206-8202','UTC-8','3923 S B ST',184),
(193000000000000219,193000000000100219,'SMF6','SMF6','CA','SACRAMENTO','95837-9109','UTC-8','4930 ALLBAUGH DR',105),
(193000000000000220,193000000000100220,'SNA4','SNA4','CA','Rialto','92376-3009','UTC-8','2496 W Walnut Ave',296),
(193000000000000221,193000000000100221,'STL3','STL3','MO','REPUBLIC','65738-2779','UTC-6','3200 E SAWYER RD',109),
(193000000000000222,193000000000100222,'STL4','STL4','IL','Edwardsville','62025-2815','UTC-6','3050 GATEWAY COMMERCE CENTER DR S',194),
(193000000000000223,193000000000100223,'SWF1','SWF1','NY','ROCK TAVERN','12575','UTC-5','635 INTERNATIONAL BLVD',122),
(193000000000000224,193000000000100224,'SWF2','SWF2','NY','Hopewell Junction','12533','UTC-5','76 Patriot Way Hopewell Junction',76),
(193000000000000225,193000000000100225,'TCY1','TCY1','CA','STOCKTON','95206','UTC-8','2690 East Arch Airport Road',41),
(193000000000000226,193000000000100226,'TCY2','TCY2','CA','STOCKTON','95215','UTC-8','6201 Newcastle Rd',43),
(193000000000000227,193000000000100227,'TEB3','TEB3','NJ','Logan Township','08085','UTC-5','2651 Oldmans Creek Rd',250),
(193000000000000228,193000000000100228,'TEB4','TEB4','NJ','PENNS GROVE','08069','UTC-5','747 COURSES LANDING RD',130),
(193000000000000229,193000000000100229,'TEB6','TEB6','NJ','Cranbury','08512','UTC-5','22 Hightstown Cranbury State Road',249),
(193000000000000230,193000000000100230,'TEB9','TEB9','NJ','Somerset','08873','UTC-5','601 Randolph Road',156),
(193000000000000231,193000000000100231,'TMB8','TMB8','FL','HOMESTEAD','33032-8597','UTC-5','27505 SW 132nd AVE',65),
(193000000000000232,193000000000100232,'TOL3','TOL3','OH','PERRYSBURG','43551','UTC-5','9240 Fremont Pike 43551-4210 - PERRYSBURG, OH - United States',18),
(193000000000000233,193000000000100233,'TPA1','TPA1','FL','Ruskin','33570','UTC-5','3350 Laurel Ridge Ave.',284),
(193000000000000234,193000000000100234,'TPA2','TPA2','FL','Lakeland','33811','UTC-5','1760 County Line Rd',283),
(193000000000000235,193000000000100235,'TPA3','TPA3','FL','Auburndale','33823','UTC-5','676 C. Fred Jones Boulevard',124),
(193000000000000236,193000000000100236,'TUL1','TUL1','KS','Coffeyville','67337','UTC-6','2654 North Highway 169',272),
(193000000000000237,193000000000100237,'VGT2','VGT2','NV','LAS VEGAS','89115-2013','UTC-8','6401 Howdy Wells Ave',172),
(193000000000000238,193000000000100238,'VUBA','VUBA','PA','Scranton','18512','UTC-5','1000 Keystone Industrial Park',228),
(193000000000000239,193000000000100239,'VUGA','VUGA','PA','Pittston','18643','UTC-5','508 Delaware Avenue West',227),
(193000000000000240,193000000000100240,'WBW2','WBW2','PA','OLYPHANT','18447','UTC-5','1300 Corporate Way',82),
(193000000000000241,193000000000100241,'XAV3','XAV3','GA','PORT WENTWORTH','31407','UTC-5','XAV3-120Crossgate Dr 31407-Port Wentworth, GA- United States',36),
(193000000000000242,193000000000100242,'XEW5','XEW5','NJ','Cinnaminson','08077-2015','UTC-5','995 Taylors Ln 08077-2015',37),
(193000000000000243,193000000000100243,'XIN5','XIN5','IN','Avon','46123-1700','UTC-5','8838 E COUNTY ROAD 100 S',25),
(193000000000000244,193000000000100244,'XLX1','XLX1','MD','HAGERSTOWN','21740','UTC-5','1115 Wesel Blvd HAGERSTOWN,MD 21740,USA',34),
(193000000000000245,193000000000100245,'XLX6','XLX6','NC','PINEVILLE','28134','UTC-5','12220 Carolina Logistics Drive PINEVILLE,NC 28134,USA',23),
(193000000000000246,193000000000100246,'XLX7','XLX7','CA','VICTORVILLE','92394','UTC-8','18580 Gateway Dr,Victorville,CA 92394,USA',63),
(193000000000000247,193000000000100247,'XPB2','XPB2','FL','APOPKA','32712','UTC-5','XPB2 - 4661 Apopka Logistics Pkwy 32712 - Apopka, FL - United States',26),
(193000000000000248,193000000000100248,'XRI3','XRI3','VA','Richmond','23234','UTC-5','4949 Commerce Rd, Richmond, VA 23234',24),
(193000000000000249,193000000000100249,'XUSB','XUSB','TX','Fort Worth','76155','UTC-6','14900 Frye Rd.',213),
(193000000000000250,193000000000100250,'XUSC','XUSC','PA','Carlisle','17013','UTC-5','40 Logistics Drive',226),
(193000000000000251,193000000000100251,'XUSD','XUSD','CA','Stockton','95206','UTC-8','1909 Zephyr St',297),
(193000000000000252,193000000000100252,'XUSE','XUSE','IN','Whitestown','46075','UTC-5','5100 S Indianapolis Rd',280);
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
DELIMITER //
CREATE PROCEDURE assert_wh_fba_public_master_data_seed()
BEGIN
IF (SELECT COUNT(*) FROM tmp_wh_fba_public_master_data) <> 252 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据源数量不是252';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fba_public_master_data
WHERE warehouse_code <> UPPER(TRIM(warehouse_code))
OR warehouse_code NOT REGEXP '^[A-Z0-9]{3,5}$'
OR TRIM(city) = ''
OR TRIM(state_province) = ''
OR TRIM(address_line1) = ''
OR TRIM(postal_code) = ''
OR TRIM(timezone) = ''
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在未清洗或必填缺失记录';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fba_public_master_data source
ON existing.id = source.warehouse_id_seed
AND (existing.tenant_id <> 0 OR existing.warehouse_code <> source.warehouse_code OR existing.deleted <> 0)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据仓库种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse_address existing
JOIN tmp_wh_fba_public_master_data source
ON existing.id = source.address_id_seed
LEFT JOIN wh_warehouse parent
ON parent.id = existing.warehouse_id
AND parent.tenant_id = existing.tenant_id
WHERE existing.tenant_id <> 0
OR existing.deleted <> 0
OR parent.warehouse_code IS NULL
OR parent.warehouse_code <> source.warehouse_code
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据地址种子ID与既有数据冲突';
END IF;
IF EXISTS (
SELECT 1
FROM wh_warehouse existing
JOIN tmp_wh_fba_public_master_data source
ON existing.tenant_id = 0
AND existing.warehouse_code = source.warehouse_code
AND existing.deleted = 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共主数据存在同编码已删除仓库,需人工确认后恢复或清理';
END IF;
END//
DELIMITER ;
CALL assert_wh_fba_public_master_data_seed();
DROP PROCEDURE IF EXISTS assert_wh_fba_public_master_data_seed;
INSERT INTO wh_warehouse (
id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone,
contact_name, contact_phone, contact_email, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.warehouse_id_seed,
0,
source.warehouse_code,
source.warehouse_name,
'FBA',
'US',
source.timezone,
NULL,
NULL,
NULL,
'ACTIVE',
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fba_public_master_data source
ON DUPLICATE KEY UPDATE
warehouse_type = VALUES(warehouse_type),
country_code = VALUES(country_code),
timezone = VALUES(timezone),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO wh_warehouse_address (
id, tenant_id, warehouse_id, address_type, country_code, state_province, city,
district, postal_code, address_line1, address_line2, latitude, longitude,
created_by, created_at, updated_by, updated_at, deleted, version
)
SELECT
source.address_id_seed,
0,
warehouse.id,
'OPERATING',
'US',
source.state_province,
source.city,
NULL,
source.postal_code,
source.address_line1,
NULL,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
NULL,
CURRENT_TIMESTAMP(3),
0,
0
FROM tmp_wh_fba_public_master_data source
JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
ON DUPLICATE KEY UPDATE
tenant_id = VALUES(tenant_id),
country_code = VALUES(country_code),
state_province = VALUES(state_province),
city = VALUES(city),
postal_code = VALUES(postal_code),
address_line1 = VALUES(address_line1),
updated_at = CURRENT_TIMESTAMP(3);
DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data;
DELIMITER //
CREATE PROCEDURE verify_wh_fba_public_master_data()
BEGIN
IF (SELECT COUNT(*)
FROM wh_warehouse warehouse
JOIN tmp_wh_fba_public_master_data source
ON source.warehouse_code = warehouse.warehouse_code
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND warehouse.country_code = 'US'
AND warehouse.deleted = 0) <> 252 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共仓库写入数量校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fba_public_master_data source
LEFT JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
LEFT JOIN wh_warehouse_address address
ON address.tenant_id = 0
AND address.warehouse_id = warehouse.id
AND address.address_type = 'OPERATING'
AND address.deleted = 0
WHERE warehouse.id IS NULL
OR address.id IS NULL
OR address.country_code <> 'US'
OR address.state_province <> source.state_province
OR address.city <> source.city
OR address.postal_code <> source.postal_code
OR address.address_line1 <> source.address_line1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址父子解析或地址字段校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM wh_service_capability capability
JOIN wh_warehouse warehouse
ON warehouse.id = capability.warehouse_id
AND warehouse.tenant_id = capability.tenant_id
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND warehouse.warehouse_code IN (SELECT warehouse_code FROM tmp_wh_fba_public_master_data)
AND capability.deleted = 0
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FBA公共地址不得创建平台作业能力';
END IF;
END//
DELIMITER ;
CALL verify_wh_fba_public_master_data();
DROP PROCEDURE IF EXISTS verify_wh_fba_public_master_data;
DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse;
DELIMITER //
CREATE PROCEDURE verify_wh_fixed_platform_warehouse()
BEGIN
IF (SELECT COUNT(*)
FROM wh_warehouse warehouse
JOIN tmp_wh_fixed_platform_warehouse source
ON source.warehouse_code = warehouse.warehouse_code
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = source.warehouse_type
AND warehouse.country_code = source.country_code
AND warehouse.deleted = 0) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓写入数量校验失败';
END IF;
IF EXISTS (
SELECT 1
FROM tmp_wh_fixed_platform_warehouse source
LEFT JOIN wh_warehouse warehouse
ON warehouse.tenant_id = 0
AND warehouse.warehouse_code = source.warehouse_code
AND warehouse.deleted = 0
LEFT JOIN wh_warehouse_address address
ON address.tenant_id = 0
AND address.warehouse_id = warehouse.id
AND address.address_type = 'OPERATING'
AND address.deleted = 0
WHERE warehouse.id IS NULL
OR address.id IS NULL
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败';
END IF;
END//
DELIMITER ;
CALL verify_wh_fixed_platform_warehouse();
DROP PROCEDURE IF EXISTS verify_wh_fixed_platform_warehouse;
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse;
@@ -2,8 +2,8 @@ SET NAMES utf8mb4;
USE baiyue_warehouse; USE baiyue_warehouse;
-- 一期联调初始化仓库,重复执行不会产生重复数据。 -- 一期联调初始化仓库,重复执行不会产生重复数据。
INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,contact_name,contact_phone,status,remark) INSERT INTO wh_warehouse(id,tenant_id,warehouse_code,warehouse_name,warehouse_type,country_code,timezone,contact_name,contact_phone,status,remark)
VALUES(190000000000000001,0,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','Asia/Shanghai','仓库收货组','0755-88886666','ACTIVE','工作日预约收货') VALUES(190000000000000001,0,'CN-SZX-01','深圳头程收货仓','DOMESTIC','CN','UTC+8','仓库收货组','0755-88886666','ACTIVE','工作日预约收货')
ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),status='ACTIVE'; ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),timezone=VALUES(timezone),status='ACTIVE';
INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1) INSERT INTO wh_warehouse_address(id,tenant_id,warehouse_id,address_type,country_code,state_province,city,district,postal_code,address_line1)
VALUES(190000000000000011,0,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓') VALUES(190000000000000011,0,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓')
ON DUPLICATE KEY UPDATE tenant_id=0,address_line1=VALUES(address_line1); ON DUPLICATE KEY UPDATE tenant_id=0,address_line1=VALUES(address_line1);
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse (
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS',
country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量',
contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人',
contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话',
contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱',
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.lang.reflect.Modifier;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Set; import java.util.Set;
@@ -27,10 +28,15 @@ class MerchantControllerIdentityContractTest {
} }
@Test @Test
void merchantWarehouseDefaultsToPlatformResourceScope() throws Exception { void merchantControllersUseFixedPlatformResourceScope() throws Exception {
var value = MerchantWarehouseController.class.getDeclaredField("publicTenantId") for (Class<?> controller : Set.of(MerchantWarehouseController.class, MerchantPartnerController.class)) {
.getAnnotation(org.springframework.beans.factory.annotation.Value.class); var value = controller.getDeclaredField("PUBLIC_TENANT_ID");
assertThat(value.value()).isEqualTo("${baiyue.warehouse.public-tenant-id:0}"); value.setAccessible(true);
assertThat(Modifier.isStatic(value.getModifiers())).isTrue();
assertThat(Modifier.isFinal(value.getModifiers())).isTrue();
assertThat(value.getLong(null)).isEqualTo(0L);
assertThat(value.getAnnotation(org.springframework.beans.factory.annotation.Value.class)).isNull();
}
} }
private static AuthenticatedUser user(String appCode, Long tenantId) { private static AuthenticatedUser user(String appCode, Long tenantId) {
@@ -49,6 +49,8 @@ class PlatformMasterDataControllerContractTest {
assertWarehouseMethod("create", PostMapping.class, "platform:warehouse:create"); assertWarehouseMethod("create", PostMapping.class, "platform:warehouse:create");
assertWarehouseMethod("update", PutMapping.class, "platform:warehouse:edit"); assertWarehouseMethod("update", PutMapping.class, "platform:warehouse:edit");
assertWarehouseMethod("status", PatchMapping.class, "platform:warehouse:status"); assertWarehouseMethod("status", PatchMapping.class, "platform:warehouse:status");
assertWarehouseMethod("importTemplate", GetMapping.class, "platform:warehouse:create");
assertWarehouseMethod("importWarehouses", PostMapping.class, "platform:warehouse:create");
Method page = java.util.Arrays.stream(PlatformWarehouseController.class.getDeclaredMethods()) Method page = java.util.Arrays.stream(PlatformWarehouseController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("page")) .filter(candidate -> candidate.getName().equals("page"))
@@ -56,6 +58,8 @@ class PlatformMasterDataControllerContractTest {
.orElseThrow(); .orElseThrow();
assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class);
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
assertThat(page.getGenericReturnType().getTypeName())
.contains("WarehouseSummaryResponse");
} }
@Test @Test
@@ -110,6 +114,23 @@ class PlatformMasterDataControllerContractTest {
} }
} }
@Test
void upsertWarehouseImportRequiresEditPermission() throws Exception {
var guard = PlatformWarehouseController.class.getDeclaredMethod("requireImportPermission",
AuthenticatedUser.class, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.class);
guard.setAccessible(true);
var createOnlyUser = new AuthenticatedUser(1L, 0L, 0L, 1L,
"admin", "PLATFORM", "WEB", java.util.Set.of("platform:warehouse:create"));
var editUser = new AuthenticatedUser(1L, 0L, 0L, 1L,
"admin", "PLATFORM", "WEB", java.util.Set.of("platform:warehouse:create", "platform:warehouse:edit"));
guard.invoke(null, createOnlyUser, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.CREATE_ONLY);
guard.invoke(null, editUser, com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.UPSERT);
assertThatThrownBy(() -> guard.invoke(null, createOnlyUser,
com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode.UPSERT))
.hasCauseInstanceOf(BizException.class);
}
private static void assertContract(Class<?> controller, String authority, String path) throws Exception { private static void assertContract(Class<?> controller, String authority, String path) throws Exception {
RequestMapping mapping = controller.getAnnotation(RequestMapping.class); RequestMapping mapping = controller.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).containsExactly(path); assertThat(mapping.value()).containsExactly(path);
@@ -0,0 +1,83 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
import com.baiyue.warehouse.mapper.WarehouseAddressMapper;
import com.baiyue.warehouse.mapper.WarehouseMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(WarehouseRepository.class)
class WarehouseRepositoryTest {
@MockBean private WarehouseMapper warehouses;
@MockBean private WarehouseAddressMapper addresses;
@MockBean private ServiceCapabilityMapper capabilities;
@Resource private WarehouseRepository repository;
@BeforeAll
static void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-test"), WarehouseEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-address-test"), WarehouseAddressEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "warehouse-capability-test"), ServiceCapabilityEntity.class);
}
@Test
void pageSummariesSearchesAddressKeywordAndHydratesPrimaryAddress() {
WarehouseAddressEntity matchedAddress = new WarehouseAddressEntity();
matchedAddress.setWarehouseId(7L);
WarehouseAddressEntity primaryAddress = new WarehouseAddressEntity();
primaryAddress.setId(70L);
primaryAddress.setWarehouseId(7L);
primaryAddress.setCountryCode("US");
primaryAddress.setStateProvince("CA");
primaryAddress.setCity("Los Angeles");
primaryAddress.setAddressLine1("123 Logistics Ave");
when(addresses.selectList(any())).thenReturn(List.of(matchedAddress), List.of(primaryAddress));
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(7L);
warehouse.setTenantId(0L);
warehouse.setWarehouseCode("FBA-LAX9");
warehouse.setWarehouseName("LAX9");
warehouse.setWarehouseType("FBA");
warehouse.setCountryCode("US");
warehouse.setTimezone("UTC-8");
warehouse.setStatus("ACTIVE");
when(warehouses.selectPage(any(), any())).thenAnswer(invocation -> {
Page<WarehouseEntity> page = invocation.getArgument(0);
page.setRecords(List.of(warehouse));
page.setTotal(1);
return page;
});
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "Los Angeles",
null, null, "FBA", "ACTIVE");
var result = repository.pageSummaries(0L, query);
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).singleElement().satisfies(item -> {
assertThat(item.warehouseCode()).isEqualTo("FBA-LAX9");
assertThat(item.primaryAddress().city()).isEqualTo("Los Angeles");
assertThat(item.primaryAddress().displayAddress()).contains("123 Logistics Ave");
});
verify(addresses, org.mockito.Mockito.times(2)).selectList(any());
verify(warehouses).selectPage(any(), any());
}
}
@@ -2,10 +2,13 @@ package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository; import com.baiyue.warehouse.repository.WarehouseRepository;
import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
@@ -17,6 +20,15 @@ import static org.mockito.Mockito.*;
class WarehouseApplicationServiceTest { class WarehouseApplicationServiceTest {
@MockBean private WarehouseRepository repository; @MockBean private WarehouseRepository repository;
@Resource private WarehouseApplicationService service; @Resource private WarehouseApplicationService service;
@BeforeEach
void setUp() {
doAnswer(invocation -> {
WarehouseEntity warehouse = invocation.getArgument(0);
warehouse.setId(1L);
return null;
}).when(repository).insertWarehouse(any(WarehouseEntity.class));
}
@Test @Test
void shouldHandleCase01() { void shouldHandleCase01() {
WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE");
@@ -40,10 +52,68 @@ class WarehouseApplicationServiceTest {
} }
@Test @Test
void pagesAllPlatformWarehousesWithoutCustomerFilter() { void pagesAllPlatformWarehousesWithoutCustomerFilter() {
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 200, null, null, null, null); WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 200, null, null, null, null, null);
when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(), 1, 200, 0, 0)); when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(), 1, 200, 0, 0));
assertThat(service.page(null, query).records()).isEmpty(); assertThat(service.page(null, query).records()).isEmpty();
verify(repository).page(null, query); verify(repository).page(null, query);
} }
@Test
void createsDomesticWarehouseWithPlatformDefaults() {
WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "CN-SZX-01", "深圳作业仓",
null, null, null, "张三", "13800138000", null, null, null,
new WarehouseRequests.AddressRequest(null, null, "广东省", "深圳市", "南山区",
"518000", "前海路 1 号", null, null, null),
java.util.List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)));
service.create(request);
var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class);
var address = org.mockito.ArgumentCaptor.forClass(WarehouseAddressEntity.class);
var capability = org.mockito.ArgumentCaptor.forClass(ServiceCapabilityEntity.class);
verify(repository).insertWarehouse(warehouse.capture());
verify(repository).insertAddress(address.capture());
verify(repository).insertCapability(capability.capture());
assertThat(warehouse.getValue().getTenantId()).isZero();
assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("DOMESTIC");
assertThat(warehouse.getValue().getCountryCode()).isEqualTo("CN");
assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC+8");
assertThat(warehouse.getValue().getStatus()).isEqualTo("ACTIVE");
assertThat(address.getValue().getAddressType()).isEqualTo("WAREHOUSE");
assertThat(address.getValue().getCountryCode()).isEqualTo("CN");
}
@Test
void fbaWarehouseUsesConfirmedStateTimezoneAndDoesNotCreateCapabilities() {
WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-LAX9", "LAX9",
"FBA", "US", null, null, null, null, null, null,
new WarehouseRequests.AddressRequest(null, "US", "CA", "Redlands", null,
"92374", "Amazon Fulfillment Center", null, null, null),
java.util.List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)));
service.create(request);
var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class);
verify(repository).insertWarehouse(warehouse.capture());
verify(repository, never()).insertCapability(any());
assertThat(warehouse.getValue().getTenantId()).isZero();
assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("FBA");
assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8");
assertThat(warehouse.getValue().getStatus()).isEqualTo("ACTIVE");
}
@Test
void fbaWarehouseInMultiTimezoneStateRequiresExplicitTimezone() {
WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "FBA-DFW", "DFW",
"FBA", "US", null, null, null, null, null, null,
new WarehouseRequests.AddressRequest(null, "US", "TX", "Dallas", null,
"75201", "Amazon", null, null, null),
null);
assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).insertWarehouse(any());
}
} }
@@ -0,0 +1,180 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.WarehouseImportDtos.ImportMode;
import com.baiyue.warehouse.dto.WarehouseImportDtos.WarehouseImportResult;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("仓库批量导入")
@SpringJUnitConfig(WarehouseImportService.class)
class WarehouseImportServiceTest {
@MockBean private WarehouseRepository repository;
@MockBean private WarehouseApplicationService warehouseService;
@Resource private WarehouseImportService service;
@Test
@DisplayName("默认 CREATE_ONLY 只新增新仓库并返回预检分类")
void shouldCreateOnlyNewWarehousesByDefault() throws Exception {
byte[] file = withRows(service.template(), new String[][] {
{"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""},
{"FBA-LAX9", "", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""}
});
WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null);
assertThat(result.success()).isTrue();
assertThat(result.committed()).isTrue();
assertThat(result.mode()).isEqualTo(ImportMode.CREATE_ONLY);
assertThat(result.createdRows()).isEqualTo(2);
assertThat(result.previewRows()).extracting("action").containsExactly("CREATE", "CREATE");
var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class);
verify(warehouseService, org.mockito.Mockito.times(2)).create(captor.capture());
assertThat(captor.getAllValues().get(0).tenantId()).isZero();
assertThat(captor.getAllValues().get(1).warehouseType()).isEqualTo("FBA");
assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9");
}
@Test
@DisplayName("任一行错误时整批零写入")
void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception {
byte[] file = withRows(service.template(), new String[][] {
{"FBA-DFW", "DFW", "FBA", "US", "TX", "Dallas", "", "Amazon FC", "75201", "", "", "", "", "", ""},
{"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "前海路 1 号", "", "", "", "", "", "", ""}
});
WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null);
assertThat(result.success()).isFalse();
assertThat(result.committed()).isFalse();
assertThat(result.createdRows()).isZero();
assertThat(result.errors()).anyMatch(error -> error.row() == 4 && error.field().equals("时区"));
verify(warehouseService, never()).create(any());
verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test
@DisplayName("UPSERT 更新现有仓库时保留联系人、备注并不重启停用数据")
void shouldPreserveContactRemarkAndInactiveStatusOnUpsert() throws Exception {
WarehouseEntity existing = new WarehouseEntity();
existing.setId(9L);
existing.setTenantId(0L);
existing.setWarehouseCode("CN-SZX-01");
existing.setWarehouseName("旧名称");
existing.setWarehouseType("DOMESTIC");
existing.setCountryCode("CN");
existing.setTimezone("UTC+8");
existing.setContactName("旧联系人");
existing.setContactPhone("旧电话");
existing.setContactEmail("old@example.com");
existing.setStatus("INACTIVE");
existing.setRemark("旧备注");
WarehouseAddressEntity address = new WarehouseAddressEntity();
address.setCountryCode("CN");
address.setStateProvince("广东省");
address.setCity("深圳市");
address.setAddressLine1("旧地址");
when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing);
when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address));
byte[] file = withRows(service.template(), new String[][] {
{"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "ACTIVE", "新联系人", "新电话", "new@example.com", "新备注"}
});
WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.UPSERT);
assertThat(result.success()).isTrue();
assertThat(result.committed()).isTrue();
assertThat(result.updatedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class);
verify(warehouseService).update(eq(0L), eq(9L), captor.capture());
assertThat(captor.getValue().contactName()).isEqualTo("旧联系人");
assertThat(captor.getValue().remark()).isEqualTo("旧备注");
assertThat(captor.getValue().status()).isEqualTo("INACTIVE");
}
@Test
@DisplayName("dryRun 只返回预检分类和计数,不写入仓库")
void shouldOnlyPreviewWhenDryRun() throws Exception {
byte[] file = withRows(service.template(), new String[][] {
{"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "南山区", "前海路 1 号", "518000", "", "", "张三", "13800138000", "", ""},
{"FBA-LAX9", "LAX9", "FBA", "US", "CA", "Redlands", "", "Amazon FC", "92374", "", "", "", "", "", ""}
});
WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null, true);
assertThat(result.success()).isTrue();
assertThat(result.committed()).isFalse();
assertThat(result.createdRows()).isEqualTo(2);
assertThat(result.previewRows()).extracting("action").containsExactly("CREATE", "CREATE");
verify(warehouseService, never()).create(any());
verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test
@DisplayName("CREATE_ONLY 遇到现有仓库时不更新并按无变化统计")
void shouldSkipExistingWarehouseInCreateOnlyMode() throws Exception {
WarehouseEntity existing = new WarehouseEntity();
existing.setId(9L);
existing.setTenantId(0L);
existing.setWarehouseCode("CN-SZX-01");
existing.setWarehouseName("旧名称");
existing.setWarehouseType("DOMESTIC");
existing.setCountryCode("CN");
existing.setTimezone("UTC+8");
existing.setStatus("ACTIVE");
WarehouseAddressEntity address = new WarehouseAddressEntity();
address.setCountryCode("CN");
address.setStateProvince("广东省");
address.setCity("深圳市");
address.setAddressLine1("旧地址");
when(repository.findByCode(0L, "CN-SZX-01")).thenReturn(existing);
when(repository.findAddresses(0L, 9L)).thenReturn(java.util.List.of(address));
byte[] file = withRows(service.template(), new String[][] {
{"CN-SZX-01", "深圳作业仓", "", "", "广东省", "深圳市", "", "新地址", "", "", "", "", "", "", ""}
});
WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, ImportMode.CREATE_ONLY);
assertThat(result.success()).isTrue();
assertThat(result.committed()).isTrue();
assertThat(result.createdRows()).isZero();
assertThat(result.updatedRows()).isZero();
assertThat(result.unchangedRows()).isEqualTo(1);
assertThat(result.previewRows()).extracting("action").containsExactly("UNCHANGED");
assertThat(result.previewRows()).extracting("reason").containsExactly("仅新增模式,现有仓库不修改");
verify(warehouseService, never()).create(any());
verify(warehouseService, never()).update(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
private static byte[] withRows(byte[] template, String[][] values) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
for (int rowIndex = 0; rowIndex < values.length; rowIndex++) {
Row row = workbook.getSheetAt(0).createRow(rowIndex + 3);
for (int column = 0; column < values[rowIndex].length; column++) {
row.createCell(column).setCellValue(values[rowIndex][column]);
}
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -0,0 +1,48 @@
-- 运行前请先在同一连接中执行 warehouse/src/main/resources/db/warehouse-public-master-data.sql。
USE baiyue_warehouse;
SELECT COUNT(*) AS fixed_platform_warehouse_count
FROM wh_warehouse
WHERE tenant_id = 0
AND warehouse_code IN ('CN-YIWU-01', 'CN-DONGGUAN-01', 'US-LAX-01', 'US-NYC-01')
AND warehouse_type IN ('DOMESTIC', 'OVERSEAS')
AND deleted = 0;
SELECT COUNT(*) AS fixed_platform_address_count
FROM wh_warehouse_address address
JOIN wh_warehouse warehouse
ON warehouse.id = address.warehouse_id
AND warehouse.tenant_id = address.tenant_id
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_code IN ('CN-YIWU-01', 'CN-DONGGUAN-01', 'US-LAX-01', 'US-NYC-01')
AND address.address_type = 'OPERATING'
AND address.deleted = 0
AND warehouse.deleted = 0;
SELECT COUNT(*) AS fba_public_warehouse_count
FROM wh_warehouse
WHERE tenant_id = 0
AND warehouse_type = 'FBA'
AND country_code = 'US'
AND deleted = 0;
SELECT COUNT(*) AS fba_public_address_count
FROM wh_warehouse_address address
JOIN wh_warehouse warehouse
ON warehouse.id = address.warehouse_id
AND warehouse.tenant_id = address.tenant_id
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND address.address_type = 'OPERATING'
AND address.country_code = 'US'
AND address.deleted = 0
AND warehouse.deleted = 0;
SELECT COUNT(*) AS fba_capability_violations
FROM wh_service_capability capability
JOIN wh_warehouse warehouse
ON warehouse.id = capability.warehouse_id
AND warehouse.tenant_id = capability.tenant_id
WHERE warehouse.tenant_id = 0
AND warehouse.warehouse_type = 'FBA'
AND capability.deleted = 0;
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = dirname(fileURLToPath(import.meta.url))
const warehouseRoot = resolve(currentDir, '..', '..', '..')
const sqlPath = resolve(warehouseRoot, 'src/main/resources/db/warehouse-public-master-data.sql')
const acceptedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.accepted.csv')
const rejectedPath = resolve(warehouseRoot, 'src/main/resources/db/master-data/fba-public-addresses.rejected.csv')
const fixedWarehousePath = resolve(warehouseRoot, 'src/main/resources/db/master-data/platform-fixed-warehouses.csv')
function parseCsv(text) {
const rows = []
let field = ''
let row = []
let quoted = false
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
field += '"'
index += 1
} else if (char === '"') {
quoted = false
} else {
field += char
}
} else if (char === '"') {
quoted = true
} else if (char === ',') {
row.push(field)
field = ''
} else if (char === '\n') {
row.push(field.replace(/\r$/, ''))
rows.push(row)
row = []
field = ''
} else {
field += char
}
}
if (field.length > 0 || row.length > 0) {
row.push(field)
rows.push(row)
}
const [headers, ...data] = rows.filter((item) => item.some((value) => value !== ''))
return data.map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])))
}
const sql = readFileSync(sqlPath, 'utf8')
const accepted = parseCsv(readFileSync(acceptedPath, 'utf8'))
const rejected = parseCsv(readFileSync(rejectedPath, 'utf8'))
const fixedWarehouses = parseCsv(readFileSync(fixedWarehousePath, 'utf8'))
assert.equal(accepted.length, 252)
assert.equal(rejected.length, 54)
assert.equal(fixedWarehouses.length, 4)
const codes = new Set()
const warehouseIds = new Set()
const addressIds = new Set()
for (const row of accepted) {
assert.match(row.warehouse_code, /^[A-Z0-9]{3,5}$/)
assert.equal(row.warehouse_code, row.warehouse_code.trim().toUpperCase())
assert.ok(row.city.trim())
assert.ok(row.state_province.trim())
assert.ok(row.address_line1.trim())
assert.ok(row.postal_code.trim())
assert.ok(row.timezone.trim())
assert.equal(row.country_code, 'US')
assert.ok(!codes.has(row.warehouse_code), `duplicate code ${row.warehouse_code}`)
assert.ok(!warehouseIds.has(row.warehouse_id_seed), `duplicate warehouse id ${row.warehouse_id_seed}`)
assert.ok(!addressIds.has(row.address_id_seed), `duplicate address id ${row.address_id_seed}`)
codes.add(row.warehouse_code)
warehouseIds.add(row.warehouse_id_seed)
addressIds.add(row.address_id_seed)
}
assert.ok(rejected.some((row) => row.reject_reason.includes('non_fba_business_node')))
assert.equal(rejected.filter((row) => row.reject_reason.includes('timezone_pending_confirmation')).length, 1)
assert.deepEqual(fixedWarehouses.map((row) => row.warehouse_code), [
'CN-YIWU-01',
'CN-DONGGUAN-01',
'US-LAX-01',
'US-NYC-01',
])
assert.deepEqual(fixedWarehouses.map((row) => row.timezone), [
'UTC+8',
'UTC+8',
'UTC-8',
'UTC-5',
])
assert.ok(fixedWarehouses.every((row) => row.address_line1.includes('待维护') || row.address_line1.includes('pending maintenance')))
assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fixed_platform_warehouse/)
assert.match(sql, /'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC\+8'/)
assert.match(sql, /'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC\+8'/)
assert.match(sql, /'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8'/)
assert.match(sql, /'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5'/)
assert.match(sql, /固定平台仓地址种子ID与既有数据冲突/)
assert.match(sql, /固定平台仓地址父子解析失败/)
assert.match(sql, /FROM tmp_wh_fixed_platform_warehouse source[\s\S]+ON DUPLICATE KEY UPDATE\s+tenant_id = VALUES\(tenant_id\);/)
assert.match(sql, /CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data/)
assert.match(sql, /JOIN wh_warehouse warehouse\s+ON warehouse\.tenant_id = 0\s+AND warehouse\.warehouse_code = source\.warehouse_code/s)
assert.match(sql, /ON DUPLICATE KEY UPDATE\s+warehouse_type = VALUES\(warehouse_type\),\s+country_code = VALUES\(country_code\),\s+timezone = VALUES\(timezone\),\s+updated_at = CURRENT_TIMESTAMP\(3\);/s)
assert.doesNotMatch(sql, /status\s*=\s*VALUES\(status\)/)
assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/)
assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/)
assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/)
assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/)
assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i)
assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/)
assert.match(sql, /FBA公共地址不得创建平台作业能力/)