优化
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
- 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
|
||||
- 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 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` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
|
||||
- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。
|
||||
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
|
||||
|
||||
@@ -3762,6 +3762,672 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
) 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
|
||||
-- ============================================================
|
||||
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(); }
|
||||
|
||||
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.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB");
|
||||
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP");
|
||||
verifyImage(file);
|
||||
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();
|
||||
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
||||
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) {}
|
||||
}
|
||||
+4
-6
@@ -10,7 +10,6 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.service.MeasurementReviewService;
|
||||
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -36,12 +35,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformInboundMeasurementReviewController {
|
||||
@Resource private MeasurementReviewService service;
|
||||
@Resource private AutomaticInboundCompletionService automaticCompletion;
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核通过")
|
||||
@Operation(summary = "测量审核通过",
|
||||
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
|
||||
description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。")
|
||||
@PostMapping("/{inboundId}/measurements/approve")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
|
||||
public ApiResponse<FeeSnapshotResponse> approve(
|
||||
@@ -53,9 +51,9 @@ public class PlatformInboundMeasurementReviewController {
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(automaticCompletion.completeApproved(
|
||||
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
|
||||
request.expectedVersion() + 1));
|
||||
// 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
|
||||
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
|
||||
|
||||
+102
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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> {
|
||||
@Update("""
|
||||
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
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
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 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");
|
||||
|
||||
@Resource
|
||||
@@ -58,7 +56,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SaveDraftRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, inboundId, request.measurementId(), request.version(),
|
||||
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
|
||||
@@ -78,35 +75,42 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
|
||||
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())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
|
||||
}
|
||||
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);
|
||||
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
|
||||
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
if (!alreadyAdvanced) {
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
inbound.setActualGrossWeightKg(confirmedWeight);
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
if (!alreadyAdvanced) {
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
if (measurement.getTotalPackageCount() != null) {
|
||||
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);
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
throw concurrentModification();
|
||||
@@ -121,7 +125,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.CreateWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
|
||||
}
|
||||
@@ -150,7 +153,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.CorrectWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
|
||||
@@ -181,7 +183,6 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
@@ -225,18 +226,12 @@ public class InboundMeasurementService {
|
||||
}
|
||||
|
||||
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
|
||||
if (weights.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片");
|
||||
}
|
||||
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
|
||||
}
|
||||
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
BigDecimal total = weights.stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
|
||||
if (!isPositive(total)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
|
||||
}
|
||||
return total;
|
||||
return isPositive(total) ? total : null;
|
||||
}
|
||||
|
||||
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
|
||||
@@ -328,12 +323,6 @@ public class InboundMeasurementService {
|
||||
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) {
|
||||
if (expected == null || !expected.equals(actual)) {
|
||||
throw concurrentModification();
|
||||
|
||||
@@ -38,7 +38,8 @@ public class MeasurementBatchService {
|
||||
|
||||
@Transactional
|
||||
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, "入库单不存在");
|
||||
}
|
||||
validateSource(command);
|
||||
@@ -50,7 +51,13 @@ public class MeasurementBatchService {
|
||||
applyTotals(measurement, calculatedItems);
|
||||
repository.insertMeasurement(measurement);
|
||||
} 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);
|
||||
applyTotals(measurement, calculatedItems);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
@@ -90,15 +97,12 @@ public class MeasurementBatchService {
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) {
|
||||
private InboundMeasurementEntity requireEditableBatch(MeasurementBatchCommand command) {
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
command.tenantId(), command.inboundId(), command.measurementId());
|
||||
if (measurement == null) {
|
||||
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())) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import org.springframework.util.StringUtils;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。
|
||||
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
|
||||
* 计费、库位和其他资料不作为本版本的强制门禁。
|
||||
*/
|
||||
@Service
|
||||
public class MeasurementReviewService {
|
||||
@@ -79,7 +80,7 @@ public class MeasurementReviewService {
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
|
||||
request.reason(), false, null, false, operatorId, operatorName));
|
||||
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));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
+2
-3
@@ -11,15 +11,14 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class MeasurementSubmissionService {
|
||||
@Resource
|
||||
private InboundMeasurementService measurements;
|
||||
@Resource
|
||||
private MeasurementLocationOccupationService locations;
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.MeasurementSubmitted submit(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
var result = measurements.submit(tenantId, inboundId, operatorId, request);
|
||||
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
|
||||
// 当前版本不把托盘/库位占用作为测量提交的强制门禁;入库主状态由客服确认测量时推进,
|
||||
// 仓储资源可在后续作业阶段继续维护。
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -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='分柜操作幂等与审计索引';
|
||||
+66
@@ -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());
|
||||
}
|
||||
}
|
||||
+36
-6
@@ -138,7 +138,7 @@ class InboundMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitRejectsAnyUnconfirmedWeightWithoutChangingState() {
|
||||
void submitDoesNotBlockOnUnconfirmedWeight() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
@@ -151,13 +151,43 @@ class InboundMeasurementServiceTest {
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(
|
||||
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,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("必须先完成确认");
|
||||
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 0));
|
||||
|
||||
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()).updateInbound(any());
|
||||
verify(repository).updateInbound(inbound);
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound(String status) {
|
||||
|
||||
@@ -104,6 +104,31 @@ class MeasurementBatchServiceTest {
|
||||
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) {
|
||||
return new MeasurementBatchCommand.Item(
|
||||
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
|
||||
|
||||
+7
-1
@@ -7,10 +7,12 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
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).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
|
||||
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
|
||||
|
||||
+4
-8
@@ -9,10 +9,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(MeasurementSubmissionService.class)
|
||||
@@ -38,7 +37,7 @@ class MeasurementSubmissionServiceTest {
|
||||
|
||||
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
|
||||
|
||||
verify(locations).occupy(10001L, 88L, 9L, 30L, 3);
|
||||
verifyNoInteractions(locations);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,10 +46,7 @@ class MeasurementSubmissionServiceTest {
|
||||
var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
|
||||
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
|
||||
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
|
||||
doThrow(new IllegalStateException("warehouse unavailable"))
|
||||
.when(locations).occupy(10001L, 88L, 9L, 30L, 3);
|
||||
|
||||
assertThatThrownBy(() -> service.submit(10001L, 88L, 9L, request))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
|
||||
verifyNoInteractions(locations);
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -61,8 +61,8 @@ public class PlatformFirstLegOrderController {
|
||||
return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿")
|
||||
@Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。")
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料")
|
||||
@Operation(summary = "平台编辑订单", description = "在待审核阶段编辑订单资料;编辑不会将订单退回草稿状态。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", 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,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.updateDraft(scopedTenant, user.userId(), id, request);
|
||||
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,16 +60,37 @@ public class OrderApplicationService {
|
||||
|
||||
@Transactional
|
||||
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);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
|
||||
throw new BizException("Only draft or rejected orders can be edited");
|
||||
OrderStatus before = order.getStatus();
|
||||
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);
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
}
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
|
||||
repository.replaceDraftChildren(tenantId, id);
|
||||
saveChildren(tenantId, userId, id, request);
|
||||
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -78,6 +78,48 @@ class OrderApplicationServiceStateTest {
|
||||
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
|
||||
void unapprovedTenantStillCannotSubmitDraft() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
+9
-7
@@ -2,9 +2,9 @@
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 不保存模板、任务、历史或文件,不连接数据库、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` |
|
||||
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
||||
| 健康状态 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` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` |
|
||||
| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
|
||||
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||
| PDF 参数配置 | `config/PrintPdfProperties.java` |
|
||||
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
|
||||
| 字体及授权 | `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` |
|
||||
| 端到端与架构约束测试 | `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 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
|
||||
- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。
|
||||
- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。
|
||||
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
|
||||
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
|
||||
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
|
||||
|
||||
+161
-7
@@ -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. 调用边界
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 本地直连:`http://127.0.0.1:9207`。
|
||||
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
|
||||
- 不把该接口加入 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.1 入仓标签
|
||||
|
||||
```http
|
||||
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
|
||||
Content-Type: application/json
|
||||
@@ -69,6 +72,57 @@ X-Request-Id: <optional-correlation-id>
|
||||
|
||||
未传 `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. 响应处理
|
||||
|
||||
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
|
||||
@@ -114,6 +168,12 @@ public interface PrintPdfClient {
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@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(
|
||||
String templateCode,
|
||||
PageSpec pageSpec,
|
||||
@@ -128,6 +188,22 @@ public interface PrintPdfClient {
|
||||
String cartonText,
|
||||
String routeText,
|
||||
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` 最多有限重试,并加入退避和抖动。
|
||||
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
|
||||
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
|
||||
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。
|
||||
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、完整 `documents[]`、二维码内容或 PDF 二进制。
|
||||
|
||||
## 6. 联调检查清单
|
||||
|
||||
1. `GET /actuator/health` 返回 `UP`。
|
||||
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. 提交多页并检查页序与数组顺序一致。
|
||||
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
|
||||
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 com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -27,6 +29,7 @@ public class InternalPdfController {
|
||||
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
|
||||
|
||||
@Resource private InboundMarkPdfService pdfService;
|
||||
@Resource private FreightA4PdfService freightA4PdfService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@@ -58,4 +61,34 @@ public class InternalPdfController {
|
||||
|
||||
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
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
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 ObjectMapper objectMapper;
|
||||
@@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
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);
|
||||
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:
|
||||
schema:
|
||||
$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:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
@@ -112,6 +160,66 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 34
|
||||
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:
|
||||
type: object
|
||||
required: [code, message]
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.web.GlobalExceptionHandler;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -38,6 +40,8 @@ class InternalPdfControllerTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
@MockBean
|
||||
private InboundMarkPdfService service;
|
||||
@MockBean
|
||||
private FreightA4PdfService freightA4PdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
@@ -127,6 +131,28 @@ class InternalPdfControllerTest {
|
||||
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
|
||||
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
|
||||
PostMapping mapping = InternalPdfController.class
|
||||
@@ -142,6 +168,7 @@ class InternalPdfControllerTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(contract)
|
||||
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
||||
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
|
||||
.contains("application/pdf:")
|
||||
.contains("application/json:")
|
||||
.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);
|
||||
}
|
||||
|
||||
@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
|
||||
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||
@@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest {
|
||||
}
|
||||
|
||||
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() {
|
||||
@Override public int getContentLength() { return -1; }
|
||||
@Override public long getContentLengthLong() { return -1; }
|
||||
} : new MockHttpServletRequest();
|
||||
initialize(request, content);
|
||||
initialize(request, content, path);
|
||||
return request;
|
||||
}
|
||||
|
||||
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.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
|
||||
request.setRequestURI(path);
|
||||
request.setContent(content.getBytes(StandardCharsets.UTF_8));
|
||||
request.setAttribute("requestId", "request-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")));
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -9,27 +9,26 @@ import com.baiyue.warehouse.service.PartnerQueryService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/partners")
|
||||
public class MerchantPartnerController {
|
||||
private static final long PUBLIC_TENANT_ID = 0L;
|
||||
|
||||
@Resource
|
||||
private PartnerQueryService service;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}")
|
||||
private long publicTenantId;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
public ApiResponse<List<PartnerEntity>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required=false) String keyword,@RequestParam(required=false) String type) {
|
||||
requireMerchant(user);
|
||||
return ApiResponse.success(service.candidates(publicTenantId,keyword,type));
|
||||
return ApiResponse.success(service.candidates(PUBLIC_TENANT_ID,keyword,type));
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<PartnerEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) {
|
||||
requireMerchant(user);
|
||||
return ApiResponse.success(service.active(publicTenantId,id));
|
||||
return ApiResponse.success(service.active(PUBLIC_TENANT_ID,id));
|
||||
}
|
||||
|
||||
private static void requireMerchant(AuthenticatedUser user) {
|
||||
|
||||
+7
-9
@@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 商户端公共仓库查询接口。认证用户共享平台默认租户维护的仓库配置。
|
||||
* 商户端公共仓库查询接口。认证用户共享平台公共作用域维护的仓库配置。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Merchant warehouses / 商户仓库",
|
||||
@@ -32,15 +31,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/warehouses")
|
||||
public class MerchantWarehouseController {
|
||||
private static final long PUBLIC_TENANT_ID = 0L;
|
||||
|
||||
@Resource
|
||||
private WarehouseApplicationService service;
|
||||
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}")
|
||||
private long publicTenantId;
|
||||
|
||||
@Operation(
|
||||
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({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@@ -51,12 +49,12 @@ public class MerchantWarehouseController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseRequests.QueryRequest query) {
|
||||
requireMerchant(user);
|
||||
return ApiResponse.success(service.merchantPage(publicTenantId, query));
|
||||
return ApiResponse.success(service.merchantPage(PUBLIC_TENANT_ID, query));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
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({
|
||||
@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)
|
||||
@PathVariable long id) {
|
||||
requireMerchant(user);
|
||||
return ApiResponse.success(service.activeDetail(publicTenantId, id));
|
||||
return ApiResponse.success(service.activeDetail(PUBLIC_TENANT_ID, id));
|
||||
}
|
||||
|
||||
private static void requireMerchant(AuthenticatedUser user) {
|
||||
|
||||
+7
-7
@@ -36,11 +36,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台库位管理接口;分页支持跨企业查询,写操作仍以目标 tenantId 隔离。 */
|
||||
/** 平台库位管理接口;统一维护平台公共作用域下的库位资源。 */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/locations")
|
||||
@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料")
|
||||
@Tag(name = "平台库位管理", description = "按平台公共作用域和仓库管理库位基础资料")
|
||||
public class PlatformLocationController {
|
||||
@Resource
|
||||
private LocationApplicationService service;
|
||||
@@ -48,14 +48,14 @@ public class PlatformLocationController {
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:location:list')")
|
||||
@Operation(summary = "分页查询库位",
|
||||
description = "默认分页查询全部企业库位;可按目标企业租户、仓库、库区、编码、类型和状态筛选")
|
||||
description = "默认分页查询全部平台公共库位;可按仓库、库区、编码、类型和状态筛选")
|
||||
@ApiResponses({
|
||||
@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(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
|
||||
@Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
|
||||
@RequestParam(required = false) @Min(0) Long tenantId,
|
||||
@Valid @ParameterObject PageQuery query) {
|
||||
requirePlatform(user);
|
||||
@@ -65,7 +65,7 @@ public class PlatformLocationController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:location:list')")
|
||||
@Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属")
|
||||
@Operation(summary = "查询库位详情", description = "详情查询同时校验平台公共作用域和仓库归属")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"),
|
||||
@@ -98,7 +98,7 @@ public class PlatformLocationController {
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:location:create')")
|
||||
@Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位")
|
||||
@Operation(summary = "新增库位", description = "在平台公共仓库内新增唯一编码库位")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"),
|
||||
|
||||
+3
-3
@@ -32,17 +32,17 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/pallets")
|
||||
@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据")
|
||||
@Tag(name = "平台托盘管理", description = "按平台公共作用域和仓库维护托盘主数据")
|
||||
public class PlatformPalletController {
|
||||
@Resource
|
||||
private PalletApplicationService service;
|
||||
|
||||
@GetMapping
|
||||
@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 权限"))
|
||||
public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "目标企业租户ID;不传时查询全部企业")
|
||||
@Parameter(description = "兼容作用域;仅允许 0,不传时查询平台公共资源")
|
||||
@RequestParam(required = false) @Min(0) Long tenantId,
|
||||
@Valid @ParameterObject PalletRequests.PageQuery query) {
|
||||
requirePlatform(user);
|
||||
|
||||
+83
-16
@@ -6,8 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.dto.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.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
|
||||
import com.baiyue.warehouse.service.WarehouseImportService;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
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.validation.Valid;
|
||||
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.core.annotation.AuthenticationPrincipal;
|
||||
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.RequestMapping;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。
|
||||
* 平台端仓库管理接口,统一维护平台公共作用域下的仓库主数据。
|
||||
*/
|
||||
@Validated
|
||||
@Tag(
|
||||
name = "Platform warehouse administration / 平台仓库管理",
|
||||
description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。"
|
||||
description = "Platform APIs for managing public warehouses. / 平台端管理公共仓库的接口。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/warehouse-admin")
|
||||
public class PlatformWarehouseController {
|
||||
@Resource
|
||||
private WarehouseApplicationService service;
|
||||
@Resource
|
||||
private WarehouseImportService importService;
|
||||
|
||||
@Operation(
|
||||
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({
|
||||
@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,
|
||||
@Valid @RequestBody WarehouseRequests.SaveRequest request) {
|
||||
requirePlatform(user);
|
||||
requireTenant(request.tenantId());
|
||||
if (request.tenantId() != null) requireTenant(request.tenantId());
|
||||
return ApiResponse.success(service.create(request));
|
||||
}
|
||||
|
||||
@@ -69,7 +84,7 @@ public class PlatformWarehouseController {
|
||||
@ApiResponses({
|
||||
@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 = "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 = "409", description = "Warehouse code already exists / 仓库编码已存在")
|
||||
})
|
||||
@@ -77,7 +92,7 @@ public class PlatformWarehouseController {
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:edit')")
|
||||
public ApiResponse<Void> update(
|
||||
@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,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id,
|
||||
@@ -90,7 +105,7 @@ public class PlatformWarehouseController {
|
||||
|
||||
@Operation(
|
||||
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({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@@ -99,19 +114,64 @@ public class PlatformWarehouseController {
|
||||
})
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:list')")
|
||||
public ApiResponse<PageResult<WarehouseEntity>> page(
|
||||
public ApiResponse<PageResult<WarehouseSummaryResponse>> page(
|
||||
@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,
|
||||
@Valid WarehouseRequests.QueryRequest request) {
|
||||
@Valid @ParameterObject WarehouseRequests.QueryRequest request) {
|
||||
requirePlatform(user);
|
||||
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(
|
||||
summary = "Get tenant warehouse details / 查询租户仓库详情",
|
||||
description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。"
|
||||
summary = "Get public warehouse details / 查询公共仓库详情",
|
||||
description = "Returns the public warehouse, addresses and service capabilities. / 返回平台公共仓库、地址及服务能力。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@@ -122,7 +182,7 @@ public class PlatformWarehouseController {
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:list')")
|
||||
public ApiResponse<WarehouseDetailResponse> detail(
|
||||
@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,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id) {
|
||||
@@ -145,7 +205,7 @@ public class PlatformWarehouseController {
|
||||
@PreAuthorize("hasAuthority('platform:warehouse:status')")
|
||||
public ApiResponse<Void> status(
|
||||
@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,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id,
|
||||
@@ -169,4 +229,11 @@ public class PlatformWarehouseController {
|
||||
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.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalTime;
|
||||
@@ -23,26 +23,26 @@ public final class WarehouseRequests {
|
||||
|
||||
@Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求")
|
||||
public record SaveRequest(
|
||||
@Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull Long tenantId,
|
||||
@Schema(description = "Compatibility tenant ID; platform APIs force it to 0 / 兼容租户字段,平台接口固定为 0", example = "0")
|
||||
Long tenantId,
|
||||
@Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 50) String warehouseCode,
|
||||
@Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 120) String warehouseName,
|
||||
@Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @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)
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String timezone,
|
||||
@Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC")
|
||||
@Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
|
||||
@Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}")
|
||||
@Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8")
|
||||
String timezone,
|
||||
@Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64)
|
||||
@Size(max = 64) String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32)
|
||||
@Size(max = 32) String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com")
|
||||
@Email String contactEmail,
|
||||
@Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String status,
|
||||
@Schema(description = "Warehouse status; defaults to ACTIVE / 仓库状态,默认 ACTIVE", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, defaultValue = "ACTIVE")
|
||||
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
|
||||
@Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500)
|
||||
@Size(max = 500) String remark,
|
||||
@Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -54,10 +54,10 @@ public final class WarehouseRequests {
|
||||
|
||||
@Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址")
|
||||
public record AddressRequest(
|
||||
@Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String addressType,
|
||||
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "Address type; defaults to WAREHOUSE / 地址类型,默认 WAREHOUSE", example = "WAREHOUSE")
|
||||
String addressType,
|
||||
@Schema(description = "ISO two-letter country code; defaults from warehouse country / ISO 两位国家代码,默认跟随仓库国家", example = "US")
|
||||
@Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "State or province / 州或省", example = "California", maxLength = 100)
|
||||
@Size(max = 100) String stateProvince,
|
||||
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -98,6 +98,8 @@ public final class WarehouseRequests {
|
||||
@Min(1) long page,
|
||||
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
||||
@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")
|
||||
String countryCode,
|
||||
@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"})
|
||||
@Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType,
|
||||
@Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||
String status
|
||||
@Pattern(regexp = "ACTIVE|INACTIVE") String status
|
||||
) {
|
||||
public QueryRequest {
|
||||
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;
|
||||
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US")
|
||||
private String countryCode;
|
||||
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles")
|
||||
@Schema(description = "UTC offset / UTC 偏移量", example = "UTC-8")
|
||||
private String timezone;
|
||||
@Schema(description = "Contact name / 联系人", example = "Alex Chen")
|
||||
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.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
@@ -14,7 +15,10 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。
|
||||
@@ -42,6 +46,12 @@ public class WarehouseRepository {
|
||||
.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) {
|
||||
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getTenantId, tenantId)
|
||||
@@ -57,11 +67,21 @@ public class WarehouseRepository {
|
||||
}
|
||||
|
||||
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>()
|
||||
.eq(tenantId != null, WarehouseEntity::getTenantId, tenantId)
|
||||
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
|
||||
.eq(StringUtils.hasText(query.warehouseType()), WarehouseEntity::getWarehouseType, query.warehouseType())
|
||||
.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);
|
||||
|
||||
if (StringUtils.hasText(query.capabilityCode())) {
|
||||
@@ -82,8 +102,19 @@ public class WarehouseRepository {
|
||||
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) {
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -104,6 +135,11 @@ public class WarehouseRepository {
|
||||
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) {
|
||||
addresses.insert(address);
|
||||
}
|
||||
@@ -111,4 +147,40 @@ public class WarehouseRepository {
|
||||
public void insertCapability(ServiceCapabilityEntity 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;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-8
@@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
@@ -34,10 +35,12 @@ public class WarehouseApplicationService {
|
||||
captureBefore = false
|
||||
)
|
||||
public Long create(WarehouseRequests.SaveRequest request) {
|
||||
ensureCodeUnique(request.tenantId(), request.warehouseCode(), null);
|
||||
WarehouseEntity warehouse = toEntity(request);
|
||||
WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize(
|
||||
request, WarehouseSaveNormalizer.PUBLIC_TENANT_ID);
|
||||
ensureCodeUnique(normalized.tenantId(), normalized.warehouseCode(), null);
|
||||
WarehouseEntity warehouse = toEntity(normalized);
|
||||
repository.insertWarehouse(warehouse);
|
||||
insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities());
|
||||
insertChildren(warehouse.getTenantId(), warehouse.getId(), normalized.address(), normalized.capabilities());
|
||||
return warehouse.getId();
|
||||
}
|
||||
|
||||
@@ -49,24 +52,29 @@ public class WarehouseApplicationService {
|
||||
description = "Update warehouse / 更新仓库"
|
||||
)
|
||||
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 / 租户不匹配");
|
||||
}
|
||||
WarehouseSaveNormalizer.NormalizedSaveRequest normalized = WarehouseSaveNormalizer.normalize(request, tenantId);
|
||||
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());
|
||||
repository.saveWarehouse(warehouse);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
WarehouseEntity warehouse = owned(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();
|
||||
warehouse.setTenantId(request.tenantId());
|
||||
warehouse.setWarehouseCode(request.warehouseCode());
|
||||
@@ -169,4 +177,5 @@ public class WarehouseApplicationService {
|
||||
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,
|
||||
|
@@ -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,
|
||||
|
@@ -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,按用户确认生成通用格式;后续运营可维护真实地址。
|
||||
|
@@ -36,7 +36,7 @@ DELIMITER ;
|
||||
-- 早期 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', '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_phone', 'VARCHAR(32) NULL AFTER contact_name');
|
||||
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;
|
||||
-- 一期联调初始化仓库,重复执行不会产生重复数据。
|
||||
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','工作日预约收货')
|
||||
ON DUPLICATE KEY UPDATE tenant_id=0,warehouse_name=VALUES(warehouse_name),status='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),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)
|
||||
VALUES(190000000000000011,0,190000000000000001,'OPERATING','CN','广东省','深圳市','宝安区','518000','航城街道示例物流园1号仓')
|
||||
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_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS',
|
||||
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_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话',
|
||||
contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱',
|
||||
|
||||
+10
-4
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -27,10 +28,15 @@ class MerchantControllerIdentityContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantWarehouseDefaultsToPlatformResourceScope() throws Exception {
|
||||
var value = MerchantWarehouseController.class.getDeclaredField("publicTenantId")
|
||||
.getAnnotation(org.springframework.beans.factory.annotation.Value.class);
|
||||
assertThat(value.value()).isEqualTo("${baiyue.warehouse.public-tenant-id:0}");
|
||||
void merchantControllersUseFixedPlatformResourceScope() throws Exception {
|
||||
for (Class<?> controller : Set.of(MerchantWarehouseController.class, MerchantPartnerController.class)) {
|
||||
var value = controller.getDeclaredField("PUBLIC_TENANT_ID");
|
||||
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) {
|
||||
|
||||
+21
@@ -49,6 +49,8 @@ class PlatformMasterDataControllerContractTest {
|
||||
assertWarehouseMethod("create", PostMapping.class, "platform:warehouse:create");
|
||||
assertWarehouseMethod("update", PutMapping.class, "platform:warehouse:edit");
|
||||
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())
|
||||
.filter(candidate -> candidate.getName().equals("page"))
|
||||
@@ -56,6 +58,8 @@ class PlatformMasterDataControllerContractTest {
|
||||
.orElseThrow();
|
||||
assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class);
|
||||
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
|
||||
assertThat(page.getGenericReturnType().getTypeName())
|
||||
.contains("WarehouseSummaryResponse");
|
||||
}
|
||||
|
||||
@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 {
|
||||
RequestMapping mapping = controller.getAnnotation(RequestMapping.class);
|
||||
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());
|
||||
}
|
||||
}
|
||||
+71
-1
@@ -2,10 +2,13 @@ package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.repository.WarehouseRepository;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -17,6 +20,15 @@ import static org.mockito.Mockito.*;
|
||||
class WarehouseApplicationServiceTest {
|
||||
@MockBean private WarehouseRepository repository;
|
||||
@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
|
||||
void shouldHandleCase01() {
|
||||
WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE");
|
||||
@@ -40,10 +52,68 @@ class WarehouseApplicationServiceTest {
|
||||
}
|
||||
@Test
|
||||
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));
|
||||
|
||||
assertThat(service.page(null, query).records()).isEmpty();
|
||||
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公共地址不得创建平台作业能力/)
|
||||
Reference in New Issue
Block a user