提交
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
|||||||
|
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
|
||||||
|
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
|
||||||
|
-- 可重复执行;授权和持久化安全版本在同一事务提交。
|
||||||
|
START TRANSACTION;
|
||||||
|
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||||
|
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
|
||||||
|
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
|
||||||
|
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||||
|
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
|
||||||
|
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
|
||||||
|
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
|
||||||
|
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||||
|
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
|
||||||
|
JOIN iam_membership m ON m.user_id=u.id
|
||||||
|
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
|
||||||
|
JOIN iam_role r ON r.id=mr.role_id
|
||||||
|
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
|
||||||
|
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
|
||||||
|
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
|
||||||
|
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||||
|
COMMIT;
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
|
||||||
|
-- 原子替换约束,可重复执行;不修改业务记录。
|
||||||
|
SET @ddl = IF(EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
|
||||||
|
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
|
||||||
|
),
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@@ -7121,7 +7121,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
|||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
|
||||||
|
|||||||
@@ -2473,7 +2473,7 @@ CREATE TABLE ll_return_inventory_carton_item (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
|
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
|
||||||
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
|
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
|
||||||
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)),
|
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity >= 0)),
|
||||||
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
|
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
||||||
|
|
||||||
@@ -4387,6 +4387,37 @@ CREATE TABLE ll_selfop_product (
|
|||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables;
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 199 AS initialized_tables;
|
||||||
|
|
||||||
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||||
|
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||||
|
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
|
|
||||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ VALUES
|
|||||||
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
|
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
|
||||||
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
|
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
|
||||||
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
|
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
|
||||||
|
(5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0),
|
||||||
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
|
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
|
||||||
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
|
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
|
||||||
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
|
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
|
||||||
@@ -336,6 +337,7 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
|
|||||||
'merchant:overseas-warehouse:discard:list',
|
'merchant:overseas-warehouse:discard:list',
|
||||||
'merchant:overseas-warehouse:discard:create',
|
'merchant:overseas-warehouse:discard:create',
|
||||||
'merchant:overseas-warehouse:discard:cancel',
|
'merchant:overseas-warehouse:discard:cancel',
|
||||||
|
'merchant:overseas-warehouse:discard:confirm',
|
||||||
'merchant:overseas-warehouse:return-order:list',
|
'merchant:overseas-warehouse:return-order:list',
|
||||||
'merchant:overseas-warehouse:return-order:create',
|
'merchant:overseas-warehouse:return-order:create',
|
||||||
'merchant:overseas-warehouse:return-order:relabel'
|
'merchant:overseas-warehouse:return-order:relabel'
|
||||||
@@ -371,9 +373,10 @@ BEGIN
|
|||||||
'merchant:overseas-warehouse:discard:list',
|
'merchant:overseas-warehouse:discard:list',
|
||||||
'merchant:overseas-warehouse:discard:create',
|
'merchant:overseas-warehouse:discard:create',
|
||||||
'merchant:overseas-warehouse:discard:cancel',
|
'merchant:overseas-warehouse:discard:cancel',
|
||||||
|
'merchant:overseas-warehouse:discard:confirm',
|
||||||
'merchant:overseas-warehouse:return-order:list',
|
'merchant:overseas-warehouse:return-order:list',
|
||||||
'merchant:overseas-warehouse:return-order:create',
|
'merchant:overseas-warehouse:return-order:create',
|
||||||
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
|
'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
|
||||||
END IF;
|
END IF;
|
||||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||||
@@ -405,9 +408,10 @@ BEGIN
|
|||||||
'merchant:overseas-warehouse:discard:list',
|
'merchant:overseas-warehouse:discard:list',
|
||||||
'merchant:overseas-warehouse:discard:create',
|
'merchant:overseas-warehouse:discard:create',
|
||||||
'merchant:overseas-warehouse:discard:cancel',
|
'merchant:overseas-warehouse:discard:cancel',
|
||||||
|
'merchant:overseas-warehouse:discard:confirm',
|
||||||
'merchant:overseas-warehouse:return-order:list',
|
'merchant:overseas-warehouse:return-order:list',
|
||||||
'merchant:overseas-warehouse:return-order:create',
|
'merchant:overseas-warehouse:return-order:create',
|
||||||
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
|
'merchant:overseas-warehouse:return-order:relabel')) <> 25 THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest {
|
|||||||
assertThat(sql).contains(
|
assertThat(sql).contains(
|
||||||
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
|
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
|
||||||
"role.role_code='MERCHANT_OWNER'",
|
"role.role_code='MERCHANT_OWNER'",
|
||||||
")) <> 24 THEN",
|
")) <> 25 THEN",
|
||||||
"客户默认管理员授权不完整");
|
"客户默认管理员授权不完整");
|
||||||
|
|
||||||
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
||||||
|
|||||||
+2
-1
@@ -36,6 +36,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
|||||||
"merchant:overseas-warehouse:discard:list",
|
"merchant:overseas-warehouse:discard:list",
|
||||||
"merchant:overseas-warehouse:discard:create",
|
"merchant:overseas-warehouse:discard:create",
|
||||||
"merchant:overseas-warehouse:discard:cancel",
|
"merchant:overseas-warehouse:discard:cancel",
|
||||||
|
"merchant:overseas-warehouse:discard:confirm",
|
||||||
"merchant:overseas-warehouse:return-order:list",
|
"merchant:overseas-warehouse:return-order:list",
|
||||||
"merchant:overseas-warehouse:return-order:create",
|
"merchant:overseas-warehouse:return-order:create",
|
||||||
"merchant:overseas-warehouse:return-order:relabel");
|
"merchant:overseas-warehouse:return-order:relabel");
|
||||||
@@ -85,7 +86,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
|||||||
"海外客户权限初始化失败:客户默认管理员授权不完整",
|
"海外客户权限初始化失败:客户默认管理员授权不完整",
|
||||||
"permission.app_code='MERCHANT'",
|
"permission.app_code='MERCHANT'",
|
||||||
"role.role_code='MERCHANT_OWNER'",
|
"role.role_code='MERCHANT_OWNER'",
|
||||||
")) <> 24 THEN");
|
")) <> 25 THEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+2
-1
@@ -1426,6 +1426,7 @@ public class DeliveryTransferService {
|
|||||||
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
|
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
|
||||||
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
||||||
.like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode())
|
.like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode())
|
||||||
|
.ne(StringUtils.hasText(query.lxCode()), OutboundOrder::getOutboundType, "DISCARD_OUTBOUND")
|
||||||
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
|
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
|
||||||
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
|
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
|
||||||
.eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType())
|
.eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType())
|
||||||
@@ -1611,7 +1612,7 @@ public class DeliveryTransferService {
|
|||||||
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
||||||
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
||||||
}
|
}
|
||||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||||
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
||||||
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
||||||
|
|||||||
-7
@@ -77,13 +77,6 @@ public class CargoDiscardController {
|
|||||||
return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true));
|
return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{discardNo}/complete")
|
|
||||||
public ApiResponse<CargoDiscardResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
|
|
||||||
requireApprovalPermission(user);
|
|
||||||
return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String scope(AuthenticatedUser user, String requested) {
|
private String scope(AuthenticatedUser user, String requested) {
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
|||||||
+6
@@ -46,6 +46,12 @@ public class MerchantCargoDiscardController {
|
|||||||
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||||
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
|
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||||
}
|
}
|
||||||
|
@PostMapping("/{discardNo}/confirm-completion")
|
||||||
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CONFIRM + "')")
|
||||||
|
public ApiResponse<CargoDiscardResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String discardNo) {
|
||||||
|
return ApiResponse.success(service.confirmCompletion(customer(user), discardNo, operator(user)));
|
||||||
|
}
|
||||||
@PostMapping("/{discardNo}/cancel")
|
@PostMapping("/{discardNo}/cancel")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
|
||||||
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ public final class CargoDiscardResponses {
|
|||||||
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
|
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
|
||||||
String overseasWarehouseId, String overseasWarehouseName,
|
String overseasWarehouseId, String overseasWarehouseName,
|
||||||
int totalPackages, BigDecimal totalFee, String currency, String status,
|
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo) {}
|
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
|
||||||
|
|
||||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||||
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
|
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ public class CargoDiscardApplication {
|
|||||||
private String createdBy;
|
private String createdBy;
|
||||||
private String reviewedBy;
|
private String reviewedBy;
|
||||||
private String reviewRemark;
|
private String reviewRemark;
|
||||||
|
private LocalDateTime customerConfirmedAt;
|
||||||
private LocalDateTime reviewedAt;
|
private LocalDateTime reviewedAt;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ public class PdaCargoDiscardService {
|
|||||||
application.setTotalPackages(actualTotal);
|
application.setTotalPackages(actualTotal);
|
||||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||||
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认");
|
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待客户确认");
|
||||||
}
|
}
|
||||||
outboundService.sync(application);
|
outboundService.sync(application);
|
||||||
return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim()))
|
return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim()))
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,7 @@ public class CargoDiscardOutboundService {
|
|||||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
||||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||||
outbound.setLxCodes(application.getInboundCodes()); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
||||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
|
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
|
||||||
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
||||||
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
||||||
@@ -116,6 +116,7 @@ public class CargoDiscardOutboundService {
|
|||||||
default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK";
|
default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK";
|
||||||
};
|
};
|
||||||
outbound.setStatus(status);
|
outbound.setStatus(status);
|
||||||
|
outbound.setLxCodes(null);
|
||||||
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
|
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
|
||||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-6
@@ -76,6 +76,8 @@ public class CargoDiscardService {
|
|||||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||||
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||||
|
|
||||||
@@ -105,7 +107,14 @@ public class CargoDiscardService {
|
|||||||
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
|
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
|
||||||
null, available, skus));
|
null, available, skus));
|
||||||
}
|
}
|
||||||
return result.stream().map(candidate -> withBoxes(customerId, candidate)).toList();
|
Map<String, String> names = result.stream().anyMatch(c -> !StringUtils.hasText(c.overseasWarehouseName()))
|
||||||
|
? warehouseNames() : Map.of();
|
||||||
|
return result.stream().map(candidate -> withBoxes(customerId, new CargoDiscardResponses.Candidate(
|
||||||
|
candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
|
||||||
|
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(),
|
||||||
|
StringUtils.hasText(candidate.overseasWarehouseName()) ? candidate.overseasWarehouseName() : names.get(candidate.overseasWarehouseId()),
|
||||||
|
candidate.inboundCreatedAt(), candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(),
|
||||||
|
candidate.palletNo(), candidate.cartons(), candidate.cartonSelectionSupported(), candidate.cartonSelectionBlockReason()))).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) {
|
private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) {
|
||||||
@@ -201,6 +210,7 @@ public class CargoDiscardService {
|
|||||||
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
|
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
|
||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||||
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||||
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
|
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
|
||||||
@@ -258,7 +268,9 @@ public class CargoDiscardService {
|
|||||||
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
||||||
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
||||||
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
|
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||||
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
|
Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName()))
|
||||||
|
? warehouseNames() : Map.of();
|
||||||
|
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(a -> summary(a, names)).toList();
|
||||||
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
|
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,12 +337,13 @@ public class CargoDiscardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) {
|
public CargoDiscardResponses.Detail confirmCompletion(String customerId, String discardNo, String operatorId) {
|
||||||
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
|
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
|
||||||
if (application == null) throw notFound("Cargo discard application not found");
|
if (application == null) throw notFound("Cargo discard application not found");
|
||||||
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
|
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
|
||||||
if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成");
|
if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成");
|
||||||
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成");
|
application.setCustomerConfirmedAt(now());
|
||||||
|
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "客户确认废弃完成");
|
||||||
return detail(customerId, discardNo);
|
return detail(customerId, discardNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,10 +491,26 @@ public class CargoDiscardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
|
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
|
||||||
|
return summary(value, StringUtils.hasText(value.getOverseasWarehouseName()) ? Map.of() : warehouseNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> warehouseNames() {
|
||||||
|
var response = warehouseDirectory.receivingWarehouses(internalToken);
|
||||||
|
if (response == null || !response.successful() || response.data() == null)
|
||||||
|
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "海外仓库目录暂不可用");
|
||||||
|
Map<String, String> names = new HashMap<>();
|
||||||
|
for (var warehouse : response.data()) {
|
||||||
|
if (warehouse.warehouseId() != null && StringUtils.hasText(warehouse.warehouseName()))
|
||||||
|
names.put(warehouse.warehouseId().toString(), warehouse.warehouseName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
|
||||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||||
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
|
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
|
||||||
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||||
value.getReviewedAt(), outboundService.outboundNo(value));
|
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public final class OverseasPermissions {
|
|||||||
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
|
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
|
||||||
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
|
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
|
||||||
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
|
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
|
||||||
|
public static final String MERCHANT_DISCARD_CONFIRM = "merchant:overseas-warehouse:discard:confirm";
|
||||||
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
|
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
|
||||||
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
|
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
|
||||||
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
|
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
|
||||||
|
|||||||
+15
@@ -77,6 +77,21 @@ public class PdaWarehouseTransferController {
|
|||||||
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{transferNo}/carton-label")
|
||||||
|
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{transferNo}/cartons/count")
|
||||||
|
public ApiResponse<WarehouseTransferResponses.Detail> countCartons(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||||
|
@Valid @RequestBody WarehouseTransferRequests.CountCartons request) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.countCartons(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{transferNo}/palletize")
|
@PostMapping("/{transferNo}/palletize")
|
||||||
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||||
|
|||||||
+26
-9
@@ -97,6 +97,24 @@ public final class WarehouseTransferRequests {
|
|||||||
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
|
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record CountCartons(
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@NotEmpty @Size(max = 5000) List<@Valid @NotNull CountCarton> boxes,
|
||||||
|
@Size(max = 1000) String discrepancyReason
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record CountCarton(
|
||||||
|
@Size(max = 255) String cartonMark,
|
||||||
|
Long transferItemId,
|
||||||
|
@NotNull Long transferPalletId,
|
||||||
|
@NotNull @DecimalMin("0.01") BigDecimal length,
|
||||||
|
@NotNull @DecimalMin("0.01") BigDecimal width,
|
||||||
|
@NotNull @DecimalMin("0.01") BigDecimal height,
|
||||||
|
@NotNull @DecimalMin("0.01") BigDecimal weight,
|
||||||
|
@NotBlank @Size(max = 255) String skuCode,
|
||||||
|
@Positive @Max(2147483647) long quantity
|
||||||
|
) {}
|
||||||
|
|
||||||
public record Palletize(
|
public record Palletize(
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
|
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
|
||||||
@@ -104,13 +122,7 @@ public final class WarehouseTransferRequests {
|
|||||||
|
|
||||||
public record PalletDraft(
|
public record PalletDraft(
|
||||||
Long palletId,
|
Long palletId,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey
|
||||||
@Positive int cartonCount,
|
|
||||||
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
|
|
||||||
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
|
|
||||||
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
|
|
||||||
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
|
|
||||||
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
|
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||||
@@ -151,8 +163,13 @@ public final class WarehouseTransferRequests {
|
|||||||
@Size(max = 128) String skuCode,
|
@Size(max = 128) String skuCode,
|
||||||
@Size(max = 255) String productNameZh,
|
@Size(max = 255) String productNameZh,
|
||||||
@Positive int packageCount,
|
@Positive int packageCount,
|
||||||
@Size(max = 255) String shippingMark
|
@Size(max = 255) String shippingMark,
|
||||||
) {}
|
@Positive Long quantity
|
||||||
|
) {
|
||||||
|
public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {
|
||||||
|
this(skuCode, productNameZh, packageCount, shippingMark, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record RelabelItem(
|
public record RelabelItem(
|
||||||
@NotBlank @Size(max = 32) String operationType,
|
@NotBlank @Size(max = 32) String operationType,
|
||||||
|
|||||||
+4
-2
@@ -6,8 +6,10 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class WarehouseTransferResponses {
|
public final class WarehouseTransferResponses {
|
||||||
private WarehouseTransferResponses() {}
|
private WarehouseTransferResponses() {}
|
||||||
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
|
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
|
||||||
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
|
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
|
||||||
|
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
|
||||||
|
BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt) {}
|
||||||
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
|
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
|
||||||
public record DestinationResult(String customerId, String transferNo) {}
|
public record DestinationResult(String customerId, String transferNo) {}
|
||||||
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
|
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
|
||||||
|
|||||||
+11
@@ -13,12 +13,23 @@ public class WarehouseTransferCarton {
|
|||||||
@TableId(type = IdType.INPUT) private Long id;
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
private Long transferOrderId;
|
private Long transferOrderId;
|
||||||
private Long transferItemId;
|
private Long transferItemId;
|
||||||
|
private Long transferPalletId;
|
||||||
private String customerId;
|
private String customerId;
|
||||||
private String cartonMark;
|
private String cartonMark;
|
||||||
private String customerCartonMark;
|
private String customerCartonMark;
|
||||||
private Integer cartonIndex;
|
private Integer cartonIndex;
|
||||||
private Integer totalCartons;
|
private Integer totalCartons;
|
||||||
private String status;
|
private String status;
|
||||||
|
private java.math.BigDecimal lengthCm;
|
||||||
|
private java.math.BigDecimal widthCm;
|
||||||
|
private java.math.BigDecimal heightCm;
|
||||||
|
private java.math.BigDecimal grossWeightKg;
|
||||||
|
private Long countedQuantity;
|
||||||
|
private String countIdempotencyKey;
|
||||||
|
private String countRequestHash;
|
||||||
|
private String discrepancyReason;
|
||||||
|
private String countedBy;
|
||||||
|
private LocalDateTime countedAt;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -15,6 +15,7 @@ public class WarehouseTransferItem {
|
|||||||
private String skuCode;
|
private String skuCode;
|
||||||
private String productNameZh;
|
private String productNameZh;
|
||||||
private Integer packageCount;
|
private Integer packageCount;
|
||||||
|
private Long declaredQuantity;
|
||||||
private String shippingMark;
|
private String shippingMark;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-42
@@ -525,7 +525,7 @@ public class WarehouseTransferService {
|
|||||||
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
|
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||||
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
|
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
|
||||||
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
|
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
|
||||||
value.getPackageCount(), value.getShippingMark())).toList();
|
value.getPackageCount(), value.getShippingMark(), value.getDeclaredQuantity())).toList();
|
||||||
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
|
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
|
||||||
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
|
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
|
||||||
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
|
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
|
||||||
@@ -535,7 +535,7 @@ public class WarehouseTransferService {
|
|||||||
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
||||||
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
|
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
|
||||||
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
|
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
|
||||||
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
|
value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
|
||||||
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
|
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
|
||||||
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
|
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
|
||||||
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
|
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
|
||||||
@@ -607,13 +607,21 @@ public class WarehouseTransferService {
|
|||||||
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WarehouseTransferResponses.Label pdaCartonLabel(long warehouseId, String transferNo) {
|
||||||
|
var transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId));
|
||||||
|
if (transfer == null) throw notFound("当前仓没有该转运单");
|
||||||
|
return cartonLabel(transfer.getCustomerId(), transferNo);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
|
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
|
||||||
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
|
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
|
||||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||||
if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels");
|
if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels");
|
||||||
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
|
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
|
||||||
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
|
.filter(value -> !"NOT_RECEIVED".equals(value.getStatus()))
|
||||||
|
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
|
||||||
StringBuilder labels = new StringBuilder();
|
StringBuilder labels = new StringBuilder();
|
||||||
for (WarehouseTransferResponses.Carton carton : cartons) {
|
for (WarehouseTransferResponses.Carton carton : cartons) {
|
||||||
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
|
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
|
||||||
@@ -689,9 +697,10 @@ public class WarehouseTransferService {
|
|||||||
WarehouseTransferRequests.PalletDraft request) {
|
WarehouseTransferRequests.PalletDraft request) {
|
||||||
WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo);
|
WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo);
|
||||||
String customerId = transfer.getCustomerId();
|
String customerId = transfer.getCustomerId();
|
||||||
validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm());
|
|
||||||
String requestHash = hash(request);
|
String requestHash = hash(request);
|
||||||
List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId());
|
List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId());
|
||||||
|
if (transferCartons.listOrder(customerId, transfer.getId()).stream().anyMatch(box -> box.getCountedAt() != null))
|
||||||
|
throw conflict("箱子清点已确认,不能再新增或修改托盘");
|
||||||
WarehouseTransferPallet pallet;
|
WarehouseTransferPallet pallet;
|
||||||
if (request.palletId() == null) {
|
if (request.palletId() == null) {
|
||||||
pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey()))
|
pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey()))
|
||||||
@@ -710,9 +719,9 @@ public class WarehouseTransferService {
|
|||||||
if (pallet == null) throw notFound("Warehouse transfer pallet draft not found");
|
if (pallet == null) throw notFound("Warehouse transfer pallet draft not found");
|
||||||
if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked");
|
if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked");
|
||||||
}
|
}
|
||||||
pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm());
|
pallet.setCartonCount(0); pallet.setLengthCm(BigDecimal.ZERO);
|
||||||
pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm());
|
pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
|
||||||
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg());
|
pallet.setVolumeCbm(BigDecimal.ZERO); pallet.setGrossWeightKg(BigDecimal.ZERO);
|
||||||
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
|
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
|
||||||
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
|
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
|
||||||
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
|
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
|
||||||
@@ -782,6 +791,94 @@ public class WarehouseTransferService {
|
|||||||
return detail(transfer.getCustomerId(), transferNo);
|
return detail(transfer.getCustomerId(), transferNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WarehouseTransferResponses.Detail countCartons(String operatorId, long warehouseId, String transferNo,
|
||||||
|
WarehouseTransferRequests.CountCartons request) {
|
||||||
|
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
||||||
|
if (transfer == null) throw notFound("当前仓没有该转运单");
|
||||||
|
String customerId = transfer.getCustomerId();
|
||||||
|
List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId());
|
||||||
|
String requestHash = hash(request);
|
||||||
|
boolean sameKey = cartons.stream().anyMatch(box -> request.idempotencyKey().equals(box.getCountIdempotencyKey()));
|
||||||
|
if (sameKey) {
|
||||||
|
if (cartons.stream().anyMatch(box -> !request.idempotencyKey().equals(box.getCountIdempotencyKey())
|
||||||
|
|| !requestHash.equals(box.getCountRequestHash()))) throw conflict("幂等键已用于其他清点内容");
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("转运单已结束入库测量");
|
||||||
|
if (cartons.stream().anyMatch(box -> box.getCountedAt() != null)) throw conflict("本单已完成清点,请刷新查看结果");
|
||||||
|
if (cartons.isEmpty()) throw conflict("转运单缺少箱号预报");
|
||||||
|
if (request.boxes().size() != transfer.getPackageCount() && !StringUtils.hasText(request.discrepancyReason()))
|
||||||
|
throw badRequest("实收箱数与预报不一致时必须填写差异原因");
|
||||||
|
Map<String, WarehouseTransferCarton> byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity()));
|
||||||
|
Map<Long, WarehouseTransferItem> byItem = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
||||||
|
.eq(WarehouseTransferItem::getCustomerId, customerId).eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()))
|
||||||
|
.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
|
||||||
|
List<WarehouseTransferPallet> countedPallets = transferPallets.listOrder(customerId, transfer.getId());
|
||||||
|
if (countedPallets.stream().anyMatch(pallet -> request.boxes().stream().noneMatch(box -> pallet.getId().equals(box.transferPalletId()))))
|
||||||
|
throw badRequest("每个托盘必须至少分配一个实收箱,请核对箱子所属托盘");
|
||||||
|
Map<Long, WarehouseTransferPallet> byPallet = countedPallets.stream().collect(Collectors.toMap(WarehouseTransferPallet::getId, Function.identity()));
|
||||||
|
Set<String> seen = new java.util.HashSet<>();
|
||||||
|
for (var input : request.boxes()) {
|
||||||
|
var targetPallet = byPallet.get(input.transferPalletId());
|
||||||
|
if (targetPallet == null || targetPallet.getWarehousePalletId() == null
|
||||||
|
|| !Set.of("GENERATED", "BOUND").contains(targetPallet.getStatus()))
|
||||||
|
throw badRequest("请先创建本单托盘号,再选择箱子所属托盘");
|
||||||
|
var carton = byMark.get(input.cartonMark());
|
||||||
|
if (StringUtils.hasText(input.cartonMark()) && (carton == null || !seen.add(input.cartonMark())))
|
||||||
|
throw badRequest("箱号不属于本单或重复填写");
|
||||||
|
if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细");
|
||||||
|
var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId());
|
||||||
|
if (item == null || !item.getSkuCode().equals(input.skuCode())) throw badRequest("箱内 SKU 必须与该箱申报明细一致");
|
||||||
|
if (input.quantity() <= 0 || input.quantity() > Integer.MAX_VALUE || input.length() == null || input.width() == null
|
||||||
|
|| input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0
|
||||||
|
|| input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (var carton : cartons) {
|
||||||
|
if (seen.contains(carton.getCartonMark())) continue;
|
||||||
|
carton.setStatus("NOT_RECEIVED"); carton.setCountedAt(now); carton.setCountedBy(operatorId);
|
||||||
|
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash);
|
||||||
|
carton.setDiscrepancyReason(trim(request.discrepancyReason())); carton.setUpdatedAt(now); transferCartons.updateById(carton);
|
||||||
|
}
|
||||||
|
int nextIndex = cartons.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0);
|
||||||
|
for (var input : request.boxes()) {
|
||||||
|
var carton = byMark.get(input.cartonMark());
|
||||||
|
boolean added = carton == null;
|
||||||
|
if (added) {
|
||||||
|
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
|
||||||
|
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
|
||||||
|
carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount());
|
||||||
|
carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now);
|
||||||
|
}
|
||||||
|
carton.setTransferPalletId(input.transferPalletId());
|
||||||
|
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
|
||||||
|
carton.setGrossWeightKg(input.weight()); carton.setCountedQuantity(input.quantity());
|
||||||
|
carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED");
|
||||||
|
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now);
|
||||||
|
carton.setDiscrepancyReason(trim(request.discrepancyReason()));
|
||||||
|
if (added) transferCartons.insert(carton); else transferCartons.updateById(carton);
|
||||||
|
}
|
||||||
|
// Aggregate only the submitted, physically assigned boxes; never infer SKU placement from declaration order.
|
||||||
|
for (var pallet : countedPallets) {
|
||||||
|
var assigned = request.boxes().stream().filter(box -> pallet.getId().equals(box.transferPalletId())).toList();
|
||||||
|
pallet.setCartonCount(assigned.size());
|
||||||
|
pallet.setVolumeCbm(assigned.stream().map(box -> box.length().multiply(box.width()).multiply(box.height())
|
||||||
|
.movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, java.math.RoundingMode.HALF_UP));
|
||||||
|
pallet.setGrossWeightKg(assigned.stream().map(WarehouseTransferRequests.CountCarton::weight).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||||
|
pallet.setUpdatedAt(now); transferPallets.updateById(pallet);
|
||||||
|
}
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
|
||||||
|
var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId());
|
||||||
|
if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null)
|
||||||
|
|| cartons.stream().noneMatch(box -> "COUNTED".equals(box.getStatus())))
|
||||||
|
throw conflict("请先完成全部箱子的尺寸测量和箱内清点");
|
||||||
|
return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
|
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
|
||||||
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
||||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||||
@@ -818,6 +915,7 @@ public class WarehouseTransferService {
|
|||||||
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
|
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
|
||||||
throw conflict("Warehouse transfer is not awaiting PDA receiving");
|
throw conflict("Warehouse transfer is not awaiting PDA receiving");
|
||||||
}
|
}
|
||||||
|
List<WarehouseTransferCarton> countedCartons = requireCountedCartons(transfer);
|
||||||
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
||||||
.eq(WarehouseTransferItem::getCustomerId, customerId)
|
.eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||||
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
|
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
|
||||||
@@ -838,8 +936,10 @@ public class WarehouseTransferService {
|
|||||||
if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) {
|
if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) {
|
||||||
throw conflict("Every pallet must be generated and scan-bound before completing palletization");
|
throw conflict("Every pallet must be generated and scan-bound before completing palletization");
|
||||||
}
|
}
|
||||||
|
if (persistedPallets.stream().anyMatch(pallet -> pallet.getCartonCount() == null || pallet.getCartonCount() <= 0)) throw conflict("每个托盘必须至少分配一个实收箱");
|
||||||
int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum();
|
int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum();
|
||||||
List<DerivedPalletAllocation> derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets);
|
if (palletCartons != countedCartons.size()) throw conflict("打托箱数必须与已清点实收箱数一致");
|
||||||
|
List<DerivedPalletAllocation> derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>();
|
List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>();
|
||||||
for (int index = 0; index < persistedPallets.size(); index++) {
|
for (int index = 0; index < persistedPallets.size(); index++) {
|
||||||
@@ -1452,29 +1552,20 @@ public class WarehouseTransferService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<DerivedPalletAllocation> derivePersistedPalletAllocations(List<WarehouseTransferItem> items,
|
static List<DerivedPalletAllocation> deriveCountedPalletAllocations(List<WarehouseTransferCarton> cartons,
|
||||||
List<WarehouseTransferPallet> pallets) {
|
List<WarehouseTransferPallet> pallets) {
|
||||||
if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete");
|
|
||||||
List<DerivedPalletAllocation> result = new ArrayList<>();
|
List<DerivedPalletAllocation> result = new ArrayList<>();
|
||||||
int itemIndex = 0;
|
Set<Long> palletIds = pallets.stream().map(WarehouseTransferPallet::getId).collect(Collectors.toSet());
|
||||||
int itemRemaining = items.get(0).getPackageCount();
|
if (cartons.stream().anyMatch(box -> box.getTransferPalletId() == null || !palletIds.contains(box.getTransferPalletId())))
|
||||||
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
|
throw conflict("每个实收箱必须分配到本单托盘");
|
||||||
int palletRemaining = pallets.get(palletIndex).getCartonCount();
|
for (int index = 0; index < pallets.size(); index++) {
|
||||||
while (palletRemaining > 0) {
|
var pallet = pallets.get(index);
|
||||||
WarehouseTransferItem item = items.get(itemIndex);
|
Map<Long, Integer> quantities = new java.util.LinkedHashMap<>();
|
||||||
if (itemIndex + 1 == items.size()) {
|
cartons.stream().filter(box -> pallet.getId().equals(box.getTransferPalletId()))
|
||||||
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining));
|
.forEach(box -> quantities.merge(box.getTransferItemId(), 1, Integer::sum));
|
||||||
break;
|
if (quantities.values().stream().mapToInt(Integer::intValue).sum() != pallet.getCartonCount())
|
||||||
}
|
throw conflict("托盘箱数与分配的实收箱不一致");
|
||||||
int allocated = Math.min(palletRemaining, itemRemaining);
|
for (var entry : quantities.entrySet()) result.add(new DerivedPalletAllocation(index, entry.getKey(), entry.getValue()));
|
||||||
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated));
|
|
||||||
palletRemaining -= allocated;
|
|
||||||
itemRemaining -= allocated;
|
|
||||||
if (itemRemaining == 0 && itemIndex + 1 < items.size()) {
|
|
||||||
itemIndex++;
|
|
||||||
itemRemaining = items.get(itemIndex).getPackageCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1796,18 +1887,17 @@ public class WarehouseTransferService {
|
|||||||
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
|
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
|
||||||
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
|
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
|
||||||
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
|
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
|
||||||
for (WarehouseTransferPalletItem sourceItem : palletItems.stream()
|
// Plan allocations are unique by inbound line + pallet. SKU breakdown
|
||||||
.filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) {
|
// belongs to transfer pallet items and must not duplicate that allocation.
|
||||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
int receivedPackages = palletItems.stream().filter(row -> row.getTransferPalletId().equals(source.getId()))
|
||||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
.mapToInt(WarehouseTransferPalletItem::getPackageCount).sum();
|
||||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
if (receivedPackages != source.getCartonCount()) throw conflict("托盘明细箱数与实收不一致");
|
||||||
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount());
|
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||||
// PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数”
|
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||||
// 直接汇总该字段,不能再保留初始化时的 0。
|
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
||||||
allocation.setPickedPackageCount(sourceItem.getPackageCount());
|
allocation.setPlannedPackageCount(receivedPackages); allocation.setCountedPackageCount(receivedPackages);
|
||||||
allocation.setActualVolumeCbm(source.getVolumeCbm());
|
allocation.setPickedPackageCount(receivedPackages); allocation.setActualVolumeCbm(source.getVolumeCbm());
|
||||||
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size());
|
plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size());
|
||||||
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
|
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
|
||||||
@@ -1879,6 +1969,7 @@ public class WarehouseTransferService {
|
|||||||
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
|
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
|
||||||
item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED"));
|
item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED"));
|
||||||
item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物"));
|
item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物"));
|
||||||
|
item.setDeclaredQuantity(source.quantity());
|
||||||
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
|
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
|
||||||
transferItems.insert(item);
|
transferItems.insert(item);
|
||||||
return item.getId();
|
return item.getId();
|
||||||
|
|||||||
@@ -3069,7 +3069,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
||||||
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
|
||||||
@@ -4005,6 +4005,37 @@ CREATE TABLE ll_selfop_product (
|
|||||||
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
|
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
|
||||||
|
|
||||||
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||||
|
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||||
|
|
||||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
||||||
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
||||||
|
|||||||
@@ -26,9 +26,27 @@ info:
|
|||||||
The application may select a subset of available cartons. PDA must finish every selected carton;
|
The application may select a subset of available cartons. PDA must finish every selected carton;
|
||||||
short actual quantities are rejected. At least one photo remains required.
|
short actual quantities are rejected. At least one photo remains required.
|
||||||
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
|
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
|
||||||
|
PDA completion leaves the application in PDA_COMPLETED (waiting for customer confirmation).
|
||||||
|
Only the authenticated merchant may confirm completion after reviewing submitted PDA photos.
|
||||||
|
Confirmation changes the application to COMPLETED, records customerConfirmedAt, and never deducts
|
||||||
|
inventory again. Identical repeated confirmation preserves the first timestamp and history.
|
||||||
|
Platform users cannot close the application on behalf of the customer.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/DiscardNo'
|
||||||
|
post:
|
||||||
|
summary: 客户确认废弃完结
|
||||||
|
description: >-
|
||||||
|
Customer scope is taken only from the authenticated merchant token.
|
||||||
|
Requires PDA_COMPLETED; repeated COMPLETED calls return the existing detail.
|
||||||
|
No request customerId, stock adjustment, or new photo upload is accepted.
|
||||||
|
The client shows submitted PDA photos before the customer confirms.
|
||||||
|
x-permissions: [merchant:overseas-warehouse:discard:confirm]
|
||||||
|
responses:
|
||||||
|
'200': { description: ApiResponse with updated Detail; summary contains customerConfirmedAt }
|
||||||
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files:
|
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/DiscardNo'
|
- $ref: '#/components/parameters/DiscardNo'
|
||||||
|
|||||||
@@ -4,6 +4,67 @@ info:
|
|||||||
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
paths:
|
paths:
|
||||||
|
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label:
|
||||||
|
get:
|
||||||
|
summary: PDA读取转运箱标签
|
||||||
|
description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含新增实收箱号。
|
||||||
|
parameters:
|
||||||
|
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||||
|
responses:
|
||||||
|
'200': {description: 统一业务响应,成功data为包含printableHtml的箱标签}
|
||||||
|
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts:
|
||||||
|
post:
|
||||||
|
summary: 先创建转运签收托盘草稿
|
||||||
|
description: 仅绑定仓PDA可操作,提交幂等键创建空托盘草稿,再调用generate生成实物托盘号。不接收手工箱数、尺寸、体积和重量,箱子清点时按归属自动汇总。
|
||||||
|
parameters:
|
||||||
|
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [idempotencyKey]
|
||||||
|
properties:
|
||||||
|
palletId: {type: integer, format: int64, nullable: true}
|
||||||
|
idempotencyKey: {type: string, maxLength: 255}
|
||||||
|
responses:
|
||||||
|
'200': {description: 统一业务响应,data为含空托盘草稿的转运详情}
|
||||||
|
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count:
|
||||||
|
post:
|
||||||
|
summary: 转运逐箱测量与清点
|
||||||
|
description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘并按申报SKU记录实点件数,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。
|
||||||
|
parameters:
|
||||||
|
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [idempotencyKey, boxes]
|
||||||
|
properties:
|
||||||
|
idempotencyKey: {type: string, maxLength: 255}
|
||||||
|
discrepancyReason: {type: string, maxLength: 1000, description: 短收超收时必填差异原因}
|
||||||
|
boxes:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 5000
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [transferPalletId, length, width, height, weight, skuCode, quantity]
|
||||||
|
properties:
|
||||||
|
cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成}
|
||||||
|
transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细}
|
||||||
|
transferPalletId: {type: integer, format: int64, description: 本单已生成的转运托盘事实ID,逐箱关联,托盘箱数体积重量由箱子汇总}
|
||||||
|
length: {type: number, minimum: 0.01, description: 箱长厘米}
|
||||||
|
width: {type: number, minimum: 0.01, description: 箱宽厘米}
|
||||||
|
height: {type: number, minimum: 0.01, description: 箱高厘米}
|
||||||
|
weight: {type: number, minimum: 0.01, description: 箱重千克}
|
||||||
|
skuCode: {type: string, maxLength: 255}
|
||||||
|
quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 箱内实点件数}
|
||||||
|
responses:
|
||||||
|
'200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量件数及清点操作人时间}
|
||||||
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
|
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
|
||||||
get:
|
get:
|
||||||
summary: 查询单条库存余额详情
|
summary: 查询单条库存余额详情
|
||||||
@@ -1214,7 +1275,7 @@ paths:
|
|||||||
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
|
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
|
||||||
post:
|
post:
|
||||||
summary: 客户创建仓储转运单
|
summary: 客户创建仓储转运单
|
||||||
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
|
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
|
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
|
||||||
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
|
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
|
||||||
@@ -1883,6 +1944,15 @@ components:
|
|||||||
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
|
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
|
||||||
required: [receiptCommands]
|
required: [receiptCommands]
|
||||||
properties:
|
properties:
|
||||||
|
packageCount: { type: integer, description: 下单预计箱数,不随实际签收覆盖 }
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
quantity: { type: integer, format: int64, nullable: true, description: SKU 申报件数,历史未申报为 null }
|
||||||
|
packageCount: { type: integer, description: 该 SKU 预计箱数 }
|
||||||
|
cartons: { type: array, items: { type: object }, description: 实际签收仅统计状态 COUNTED 的箱子 }
|
||||||
receiptCommands:
|
receiptCommands:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
|
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
|
||||||
|
|||||||
+10
@@ -672,6 +672,16 @@ class DeliveryTransferServiceTest {
|
|||||||
assertEquals(4, detail.pallets().get(0).packageCount());
|
assertEquals(4, detail.pallets().get(0).packageCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"DISCARD_OUTBOUND", "ONE_CLICK_DROPSHIP"})
|
||||||
|
void discardDoesNotExposeHistoricInboundCodeAsLxButDropshipKeepsRealLx(String type) {
|
||||||
|
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
|
||||||
|
outbound.setOutboundType(type); outbound.setLxCodes("LX-REAL-001");
|
||||||
|
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||||
|
var detail = service.outboundDetail("OUT-301");
|
||||||
|
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||||
|
|||||||
+41
@@ -3,10 +3,51 @@ package com.baiyue.lastleg.discard;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
class CargoDiscardSchemaContractTest {
|
class CargoDiscardSchemaContractTest {
|
||||||
|
@Test
|
||||||
|
void returnCartonDeductionAllowsZeroButRejectsNegativeStock() throws Exception {
|
||||||
|
String baseline;
|
||||||
|
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
|
||||||
|
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
var constraint = java.util.regex.Pattern.compile(
|
||||||
|
"CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK\\(([^)]+)\\)")
|
||||||
|
.matcher(baseline);
|
||||||
|
assertTrue(constraint.find());
|
||||||
|
String deduction = ReturnInventoryCartonItemMapper.class.getMethod("deduct", long.class, long.class)
|
||||||
|
.getAnnotation(Update.class).value()[0]
|
||||||
|
.replace("#{quantity}", "?").replace("#{id}", "?");
|
||||||
|
try (var connection = DriverManager.getConnection("jdbc:h2:mem:discard_zero_quantity;MODE=MySQL")) {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.execute("CREATE TABLE ll_return_inventory_carton_item (id BIGINT PRIMARY KEY, "
|
||||||
|
+ "quantity BIGINT NOT NULL, CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK("
|
||||||
|
+ constraint.group(1) + "))");
|
||||||
|
statement.execute("INSERT INTO ll_return_inventory_carton_item VALUES (1, 3)");
|
||||||
|
try (var update = connection.prepareStatement(deduction)) {
|
||||||
|
update.setLong(1, 3); update.setLong(2, 1); update.setLong(3, 3);
|
||||||
|
assertEquals(1, update.executeUpdate());
|
||||||
|
update.setLong(1, 1); update.setLong(3, 1);
|
||||||
|
assertEquals(0, update.executeUpdate());
|
||||||
|
}
|
||||||
|
try (var result = statement.executeQuery("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1")) {
|
||||||
|
assertTrue(result.next());
|
||||||
|
assertEquals(0, result.getLong(1));
|
||||||
|
}
|
||||||
|
assertThrows(SQLException.class, () -> statement.executeUpdate(
|
||||||
|
"UPDATE ll_return_inventory_carton_item SET quantity=-1 WHERE id=1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
|
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
|
||||||
String sql;
|
String sql;
|
||||||
|
|||||||
+16
@@ -15,6 +15,22 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class MerchantCargoDiscardControllerTest {
|
class MerchantCargoDiscardControllerTest {
|
||||||
|
@Test
|
||||||
|
void completionUsesAuthenticatedCustomerAndIndependentPermission() throws Exception {
|
||||||
|
controller.confirmCompletion(user(100L, "MERCHANT"), "CD001");
|
||||||
|
verify(service).confirmCompletion("100", "CD001", "1");
|
||||||
|
assertThrows(ResponseStatusException.class,
|
||||||
|
() -> controller.confirmCompletion(user(0L, "PLATFORM"), "CD001"));
|
||||||
|
var guard = MerchantCargoDiscardController.class.getMethod("confirmCompletion", AuthenticatedUser.class, String.class)
|
||||||
|
.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class);
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"hasAuthority('merchant:overseas-warehouse:discard:confirm')", guard.value());
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(java.util.Arrays.stream(CargoDiscardController.class.getDeclaredMethods())
|
||||||
|
.filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class))
|
||||||
|
.flatMap(method -> java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()))
|
||||||
|
.anyMatch(path -> path.endsWith("/complete") || path.endsWith("/confirm-completion")));
|
||||||
|
}
|
||||||
|
|
||||||
private final CargoDiscardService service = mock(CargoDiscardService.class);
|
private final CargoDiscardService service = mock(CargoDiscardService.class);
|
||||||
private final MerchantCargoDiscardController controller = controller();
|
private final MerchantCargoDiscardController controller = controller();
|
||||||
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
|
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
|
||||||
|
|||||||
+9
@@ -43,6 +43,7 @@ class CargoDiscardOutboundServiceTest {
|
|||||||
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
|
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
|
||||||
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
|
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
|
||||||
application.setTotalPackages(5); application.setStatus("APPROVED");
|
application.setTotalPackages(5); application.setStatus("APPROVED");
|
||||||
|
application.setInboundCodes("RT-123,RK-456");
|
||||||
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
|
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
|
||||||
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
|
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
|
||||||
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
|
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
|
||||||
@@ -63,6 +64,7 @@ class CargoDiscardOutboundServiceTest {
|
|||||||
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
|
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
|
||||||
service.create(application, "OP"); service.create(application, "OP");
|
service.create(application, "OP"); service.create(application, "OP");
|
||||||
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
|
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
|
||||||
|
assertNull(document.getLxCodes());
|
||||||
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
|
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
|
||||||
assertTrue(service.outboundNo(application).startsWith("OB-"));
|
assertTrue(service.outboundNo(application).startsWith("OB-"));
|
||||||
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
|
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
|
||||||
@@ -82,6 +84,13 @@ class CargoDiscardOutboundServiceTest {
|
|||||||
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
|
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
|
||||||
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
|
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
|
||||||
}
|
}
|
||||||
|
@Test void syncClearsHistoricallyMislabelledInboundCodesWithoutChangingSourceSnapshots() {
|
||||||
|
first.setInboundCode("RK-456");
|
||||||
|
service.create(application, "OP"); document.setLxCodes("RT-123,RK-456");
|
||||||
|
service.sync(application);
|
||||||
|
assertNull(document.getLxCodes()); assertEquals("RK-456", snapshots.get(0).getInboundCode());
|
||||||
|
assertEquals("RT-123,RK-456", application.getInboundCodes());
|
||||||
|
}
|
||||||
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
|
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
|
||||||
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
|
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
|
||||||
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
|
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
|
||||||
|
|||||||
+46
-1
@@ -47,6 +47,36 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class CargoDiscardServiceTest {
|
class CargoDiscardServiceTest {
|
||||||
|
@Test
|
||||||
|
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
|
||||||
|
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||||
|
application.setId(1001L); application.setCustomerId("100"); application.setDiscardNo("CD001");
|
||||||
|
application.setTotalPackages(1); application.setOverseasWarehouseName("测试仓"); application.setStatus(CargoDiscardService.PDA_COMPLETED);
|
||||||
|
storedApplication.set(application);
|
||||||
|
when(applications.lockNo("100", "CD001")).thenReturn(application);
|
||||||
|
|
||||||
|
service.confirmCompletion("100", "CD001", "CUSTOMER");
|
||||||
|
var confirmedAt = application.getCustomerConfirmedAt();
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotNull(confirmedAt);
|
||||||
|
assertEquals(CargoDiscardService.COMPLETED, application.getStatus());
|
||||||
|
assertEquals("客户确认废弃完成", storedHistories.get(0).getRemark());
|
||||||
|
service.confirmCompletion("100", "CD001", "CUSTOMER");
|
||||||
|
assertEquals(confirmedAt, application.getCustomerConfirmedAt());
|
||||||
|
assertEquals(1, storedHistories.size());
|
||||||
|
verify(applications, times(1)).updateById(application);
|
||||||
|
org.mockito.Mockito.verifyNoInteractions(adjustments, inventory, returnCartonItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.ValueSource(strings = {"PENDING_REVIEW", "APPROVED", "REJECTED", "CANCELLED"})
|
||||||
|
void customerCannotConfirmBeforePdaCompletion(String status) {
|
||||||
|
CargoDiscardApplication application = new CargoDiscardApplication(); application.setStatus(status);
|
||||||
|
when(applications.lockNo("100", "CD001")).thenReturn(application);
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||||
|
() -> service.confirmCompletion("100", "CD001", "CUSTOMER"));
|
||||||
|
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Mock CargoDiscardApplicationMapper applications;
|
@Mock CargoDiscardApplicationMapper applications;
|
||||||
@Mock CargoDiscardItemMapper items;
|
@Mock CargoDiscardItemMapper items;
|
||||||
@Mock CargoDiscardCartonMapper discardCartons;
|
@Mock CargoDiscardCartonMapper discardCartons;
|
||||||
@@ -57,6 +87,7 @@ class CargoDiscardServiceTest {
|
|||||||
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
||||||
@Mock ReturnInventoryCartonMapper returnCartons;
|
@Mock ReturnInventoryCartonMapper returnCartons;
|
||||||
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
|
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
|
||||||
|
@Mock com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
|
||||||
|
|
||||||
private CargoDiscardService service;
|
private CargoDiscardService service;
|
||||||
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
|
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
|
||||||
@@ -68,6 +99,9 @@ class CargoDiscardServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new CargoDiscardService();
|
service = new CargoDiscardService();
|
||||||
|
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
|
||||||
|
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
|
||||||
|
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
|
||||||
ReflectionTestUtils.setField(service, "outboundService", outboundService);
|
ReflectionTestUtils.setField(service, "outboundService", outboundService);
|
||||||
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
|
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
|
||||||
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
|
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
|
||||||
@@ -97,6 +131,7 @@ class CargoDiscardServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void createReservesExactSkuCartonsWithoutDeductingInventory() {
|
void createReservesExactSkuCartonsWithoutDeductingInventory() {
|
||||||
LastlegOrder order = order();
|
LastlegOrder order = order();
|
||||||
|
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
|
||||||
DropshipSkuInventory sku = skuInventory();
|
DropshipSkuInventory sku = skuInventory();
|
||||||
when(orders.selectById(101L)).thenReturn(order);
|
when(orders.selectById(101L)).thenReturn(order);
|
||||||
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
|
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
|
||||||
@@ -109,6 +144,7 @@ class CargoDiscardServiceTest {
|
|||||||
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
|
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
|
||||||
|
|
||||||
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
|
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
|
||||||
|
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
|
||||||
assertEquals(2, detail.summary().totalPackages());
|
assertEquals(2, detail.summary().totalPackages());
|
||||||
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
|
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
|
||||||
assertEquals(2, storedLinks.size());
|
assertEquals(2, storedLinks.size());
|
||||||
@@ -116,6 +152,14 @@ class CargoDiscardServiceTest {
|
|||||||
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
|
||||||
|
CargoDiscardApplication application = pendingApplication();
|
||||||
|
application.setOverseasWarehouseId("99"); application.setOverseasWarehouseName(null);
|
||||||
|
storedApplication.set(application);
|
||||||
|
assertEquals("洛杉矶仓", service.detail("100", application.getDiscardNo()).summary().overseasWarehouseName());
|
||||||
|
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
|
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
|
||||||
when(orders.selectById(101L)).thenReturn(order());
|
when(orders.selectById(101L)).thenReturn(order());
|
||||||
@@ -159,7 +203,7 @@ class CargoDiscardServiceTest {
|
|||||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||||
carton.setId(901L);
|
carton.setId(901L);
|
||||||
carton.setCustomerId("100");
|
carton.setCustomerId("100");
|
||||||
carton.setOverseasWarehouseId("WH-1");
|
carton.setOverseasWarehouseId("99");
|
||||||
carton.setSourceReturnNo("RT-20260928-001");
|
carton.setSourceReturnNo("RT-20260928-001");
|
||||||
carton.setCartonMark("RC-LAX-001");
|
carton.setCartonMark("RC-LAX-001");
|
||||||
carton.setStatus("AVAILABLE");
|
carton.setStatus("AVAILABLE");
|
||||||
@@ -176,6 +220,7 @@ class CargoDiscardServiceTest {
|
|||||||
|
|
||||||
assertEquals(1, result.size());
|
assertEquals(1, result.size());
|
||||||
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
||||||
|
assertEquals("洛杉矶仓", result.get(0).overseasWarehouseName());
|
||||||
assertEquals(901L, result.get(0).returnCartonId());
|
assertEquals(901L, result.get(0).returnCartonId());
|
||||||
assertEquals("RC-LAX-001", result.get(0).cartonMark());
|
assertEquals("RC-LAX-001", result.get(0).cartonMark());
|
||||||
assertEquals(4, result.get(0).availablePackages());
|
assertEquals(4, result.get(0).availablePackages());
|
||||||
|
|||||||
+10
@@ -18,6 +18,16 @@ class WarehouseTransferCargoValidationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skuUnitsAndExpectedCartonsAreIndependent() {
|
||||||
|
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||||
|
var validator = factory.getValidator();
|
||||||
|
assertTrue(validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 120L)).isEmpty());
|
||||||
|
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 0L)).isEmpty());
|
||||||
|
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 0, null, 120L)).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
|
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
|
||||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package com.baiyue.lastleg.transfer.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.*;
|
||||||
|
import com.baiyue.lastleg.transfer.mapper.*;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
|
class WarehouseTransferCartonCountTest {
|
||||||
|
WarehouseTransferService service;
|
||||||
|
WarehouseTransferOrderMapper orders = mock(WarehouseTransferOrderMapper.class);
|
||||||
|
WarehouseTransferCartonMapper cartons = mock(WarehouseTransferCartonMapper.class);
|
||||||
|
WarehouseTransferItemMapper items = mock(WarehouseTransferItemMapper.class);
|
||||||
|
WarehouseTransferPalletMapper palletMapper = mock(WarehouseTransferPalletMapper.class);
|
||||||
|
WarehouseTransferPallet pallet;
|
||||||
|
WarehouseTransferOrder order;
|
||||||
|
WarehouseTransferCarton a, b;
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
service = spy(new WarehouseTransferService());
|
||||||
|
ReflectionTestUtils.setField(service, "transfers", orders);
|
||||||
|
ReflectionTestUtils.setField(service, "transferCartons", cartons);
|
||||||
|
ReflectionTestUtils.setField(service, "transferItems", items);
|
||||||
|
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
|
||||||
|
pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setStatus("GENERATED"); pallet.setWarehousePalletId(900L);
|
||||||
|
when(palletMapper.listOrder("C1", 1L)).thenReturn(List.of(pallet));
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||||
|
doReturn(null).when(service).detail(anyString(), anyString());
|
||||||
|
order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C1"); order.setTransferNo("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2);
|
||||||
|
a = box("BOX1", 10L); b = box("BOX2", 11L);
|
||||||
|
when(orders.lockForPda("WT1", "77")).thenReturn(order);
|
||||||
|
when(cartons.listOrder("C1", 1L)).thenReturn(List.of(a,b));
|
||||||
|
var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A");
|
||||||
|
var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B");
|
||||||
|
when(items.selectList(any())).thenReturn(List.of(ia,ib));
|
||||||
|
}
|
||||||
|
WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; }
|
||||||
|
WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),sku,quantity); }
|
||||||
|
WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); }
|
||||||
|
@Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() {
|
||||||
|
var request = request("K1",input("BOX1","A",12),input("BOX2","B",7));
|
||||||
|
service.countCartons("OP1",77L,"WT1",request);
|
||||||
|
assertEquals(12L,a.getCountedQuantity()); assertEquals(7L,b.getCountedQuantity()); assertNotNull(a.getCountedAt()); assertEquals("OP1",a.getCountedBy()); assertEquals(new BigDecimal("2.5"),b.getGrossWeightKg());
|
||||||
|
assertEquals(200L,a.getTransferPalletId()); assertEquals(2,pallet.getCartonCount());
|
||||||
|
assertEquals(new BigDecimal("0.048000"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg());
|
||||||
|
clearInvocations(cartons); order.setStatus("WAIT_CUSTOMER_CONFIRMATION");
|
||||||
|
service.countCartons("OP1",77L,"WT1",request);
|
||||||
|
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||||
|
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7))));
|
||||||
|
}
|
||||||
|
@Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() {
|
||||||
|
var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","B",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2)));
|
||||||
|
for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request));
|
||||||
|
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||||
|
}
|
||||||
|
@Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() {
|
||||||
|
var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱");
|
||||||
|
service.countCartons("OP1",77L,"WT1",request);
|
||||||
|
assertEquals("COUNTED",a.getStatus()); assertEquals("NOT_RECEIVED",b.getStatus());
|
||||||
|
assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity());
|
||||||
|
}
|
||||||
|
@Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() {
|
||||||
|
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,"B",4);
|
||||||
|
service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱"));
|
||||||
|
var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class);
|
||||||
|
verify(cartons).insert(captured.capture());
|
||||||
|
assertEquals("WT1#EX-12",captured.getValue().getCartonMark()); assertEquals("C1",captured.getValue().getCustomerId()); assertEquals(4L,captured.getValue().getCountedQuantity());
|
||||||
|
}
|
||||||
|
@Test void cartonsRequireGeneratedPalletOwnedByThisOrder() {
|
||||||
|
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of());
|
||||||
|
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
|
||||||
|
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||||
|
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of(pallet)); pallet.setStatus("DRAFT");
|
||||||
|
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
|
||||||
|
}
|
||||||
|
@Test void multiSkuPalletCreatesOneInboundLineAllocationWithSummedBoxes() {
|
||||||
|
var plans = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper.class);
|
||||||
|
var lines = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper.class);
|
||||||
|
var pallets = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper.class);
|
||||||
|
var allocations = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper.class);
|
||||||
|
ReflectionTestUtils.setField(service,"inboundPlans",plans); ReflectionTestUtils.setField(service,"inboundPlanLines",lines);
|
||||||
|
ReflectionTestUtils.setField(service,"inboundPlanPallets",pallets); ReflectionTestUtils.setField(service,"inboundPlanAllocations",allocations);
|
||||||
|
order.setLastlegOrderId(9L); order.setInboundCode("WT1");
|
||||||
|
var plan = new com.baiyue.lastleg.inboundplan.entity.InboundPlan(); plan.setId(100L);
|
||||||
|
var line = new com.baiyue.lastleg.inboundplan.entity.InboundPlanLine(); line.setId(101L);
|
||||||
|
when(plans.findBySourceOrder("C1",9L)).thenReturn(plan); when(lines.selectOne(any())).thenReturn(line);
|
||||||
|
var pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setCartonCount(3); pallet.setVolumeCbm(BigDecimal.ONE); pallet.setSequenceNo(1);
|
||||||
|
var first = new WarehouseTransferPalletItem(); first.setTransferPalletId(200L); first.setTransferItemId(10L); first.setPackageCount(1);
|
||||||
|
var second = new WarehouseTransferPalletItem(); second.setTransferPalletId(200L); second.setTransferItemId(11L); second.setPackageCount(2);
|
||||||
|
ReflectionTestUtils.invokeMethod(service,"completeTransferInboundPlan","C1",order,List.of(pallet),List.of(first,second),java.time.LocalDateTime.now());
|
||||||
|
var result=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
|
||||||
|
verify(allocations,times(1)).insert(result.capture()); assertEquals(3,result.getValue().getPickedPackageCount()); assertEquals(3,result.getValue().getCountedPackageCount());
|
||||||
|
}
|
||||||
|
@Test void otherWarehouseCannotReadOrWriteAndCompletedCountsCannotBeOverwritten() {
|
||||||
|
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",88L,"WT1",request("K",input("BOX1","A",1))));
|
||||||
|
service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2)));
|
||||||
|
clearInvocations(cartons);
|
||||||
|
assertThrows(ResponseStatusException.class,()->service.countCartons("OP2",77L,"WT1",request("NEW",input("BOX1","A",9),input("BOX2","B",2))));
|
||||||
|
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-13
@@ -48,19 +48,17 @@ class WarehouseTransferPalletAllocationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void persistedOverReceiptUsesTheSameStableAllocationRule() {
|
void countedBoxesDetermineSkuPlacementRatherThanDeclarationOrder() {
|
||||||
WarehouseTransferPallet first = persistedPallet(4);
|
WarehouseTransferPallet first = persistedPallet(2); first.setId(100L);
|
||||||
WarehouseTransferPallet second = persistedPallet(3);
|
WarehouseTransferPallet second = persistedPallet(1); second.setId(200L);
|
||||||
|
var a = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); a.setTransferItemId(12L); a.setTransferPalletId(100L);
|
||||||
var allocations = WarehouseTransferService.derivePersistedPalletAllocations(
|
var b = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); b.setTransferItemId(12L); b.setTransferPalletId(100L);
|
||||||
List.of(item(11L, 3), item(12L, 2)), List.of(first, second));
|
var c = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); c.setTransferItemId(11L); c.setTransferPalletId(200L);
|
||||||
|
var allocations = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second));
|
||||||
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
|
assertEquals(List.of(new WarehouseTransferService.DerivedPalletAllocation(0,12L,2),new WarehouseTransferService.DerivedPalletAllocation(1,11L,1)),allocations);
|
||||||
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L)
|
c.setTransferPalletId(999L);
|
||||||
.mapToInt(value -> value.packageCount()).sum());
|
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||||
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L)
|
() -> WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second)));
|
||||||
.mapToInt(value -> value.packageCount()).sum());
|
|
||||||
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+8
@@ -322,6 +322,10 @@ class WarehouseTransferServiceTest {
|
|||||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||||
transfer.setOperatingWarehouseId("77");
|
transfer.setOperatingWarehouseId("77");
|
||||||
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
||||||
|
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
|
||||||
|
var box = new WarehouseTransferCarton(); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
|
||||||
|
}).toList();
|
||||||
|
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
|
||||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||||
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
|
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
|
||||||
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
||||||
@@ -366,6 +370,10 @@ class WarehouseTransferServiceTest {
|
|||||||
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
||||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
|
||||||
|
var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
|
||||||
|
}).toList();
|
||||||
|
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
|
||||||
var result = service.palletize("OP-1", 77L, "WTR-1",
|
var result = service.palletize("OP-1", 77L, "WTR-1",
|
||||||
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
|
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
|
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
|
||||||
|
|
||||||
|
`db/cargo-discard-consolidated-incremental-20261014.sql`:废弃业务结构增量汇总,MySQL 8.0.16+,仅对 `baiyue_lastleg` 已有业务库执行。覆盖废弃基础表、PDA完工字段、照片关联、退货箱废弃状态、通用库存来源和部分选箱;表/列/索引按存在性判断,约束原子替换,可重复执行。不回填历史订单、不补造出库单、不修改库存数量,无业务 DML;File及仓储资源库无需本次SQL。实例和凭据由执行者指定,完整执行、遇错停止、不使用 `--force`;DDL隐式提交,修正错误后可重跑,末尾 `SHOW CREATE TABLE` 结果由执行者保存核对,不自动清理或连接环境。
|
||||||
|
|
||||||
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
|
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
|
||||||
|
|
||||||
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
|
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
|
||||||
@@ -506,3 +508,13 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
|
|||||||
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
|
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
|
||||||
|
|
||||||
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
|
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
|
||||||
|
|
||||||
|
废弃明细字段专项修复:db/cargo-discard-item-columns-repair-20261014.sql。仅对 MySQL 8.0.16+ 的 baiyue_lastleg 已有 ll_cargo_discard_item 表补齐 10 个新增字段,逐列判断,可重复执行,独立于退货约束。实例和凭据由执行者指定;不写业务数据,不修改库存,不自动访问环境。完整执行、遇错停止,DDL隐式提交,修正错误后可重跑;末尾只读查询应返回10行,由执行者保存结果,无自动清理。
|
||||||
|
|
||||||
|
废弃退货箱归零约束修复:db/cargo-discard-return-quantity-repair-20261014.sql。仅 baiyue_lastleg,MySQL 8.0.16+,将箱内SKU数量约束改为非负以支持整箱废弃。实例及凭据由执行者指定;可重复执行,原子替换约束,无业务DML,不连接环境自动执行。整份执行、遇错停止,DDL隐式提交,修正后可重跑;末尾输出表结构供保存核对,不清理业务数据。正式增量为 deploy/sql/migrations/overseas/baiyue_lastleg/V26__allow_zero_return_carton_item_quantity.sql,已纳入废弃汇总脚本。
|
||||||
|
|
||||||
|
客户废弃照片确认增量:db/cargo-discard-customer-confirmation-incremental-20261014.sql。MySQL 8.0.16+,目标明确为 baiyue_lastleg 和 baiyue_iam;连接实例与凭据由执行者指定。补齐客户确认时间和独立确认权限,默认客户管理员获得授权,自定义角色需手动分配;授权与受影响管理员的安全版本在同一事务提交,重跑安全版本仍单调递增。完整执行、遇错停止、修正后可重跑;不确认或回填历史单据,不修改库存,不自动访问环境或清理。执行结果由操作者保存,权限更新后需重新登录客户账号。对应正式迁移为 Lastleg V27 和 IAM V6;未执行时页面权限与服务字段尚不可用。
|
||||||
|
|
||||||
|
`db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。
|
||||||
|
|
||||||
|
`local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
-- 货物废弃增量汇总(2026-10-14),MySQL 8.0.16+,可重复执行。
|
||||||
|
-- 目标仅 baiyue_lastleg,连接实例和凭据由执行者指定。
|
||||||
|
-- 前提:已有尾程入仓箱、退货库存及出库基线;不是空库初始化脚本。
|
||||||
|
-- 覆盖基础废弃表、PDA完工、退货箱废弃、照片、通用库存及部分选箱。
|
||||||
|
-- 不回填历史单据、不补造出库单、不修改库存数量、不执行业务DML。
|
||||||
|
-- File复用已有文件表,无需修改 baiyue_file 或仓储资源库。
|
||||||
|
-- 完整执行,遇错停止,不使用 --force;DDL隐式提交,失败修正后可重跑。
|
||||||
|
-- 已有同名表/列/索引沿用,不修复任意未知旧结构,末尾输出结构供核对。
|
||||||
|
USE `baiyue_lastleg`;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version;
|
||||||
|
|
||||||
|
-- 一、废弃业务表,存在则保留。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
||||||
|
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
|
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认完成时间',
|
||||||
|
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
|
||||||
|
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
|
||||||
|
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
|
||||||
|
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||||
|
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||||
|
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||||
|
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||||
|
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
|
||||||
|
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
|
||||||
|
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
|
||||||
|
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='货物废弃申请单';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||||
|
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
|
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
|
||||||
|
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
|
||||||
|
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||||
|
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
|
||||||
|
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
|
||||||
|
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
|
||||||
|
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
|
||||||
|
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
|
||||||
|
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||||
|
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||||
|
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
|
||||||
|
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
|
||||||
|
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
|
||||||
|
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
|
||||||
|
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||||
|
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
|
||||||
|
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
|
||||||
|
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
|
||||||
|
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
|
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
|
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='货物废弃审批历史';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
customer_id VARCHAR(64) NOT NULL,
|
||||||
|
application_id BIGINT NOT NULL,
|
||||||
|
pallet_no VARCHAR(128) NOT NULL,
|
||||||
|
file_id BIGINT NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(128) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
operator_id VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME(6) NOT NULL,
|
||||||
|
submitted_at DATETIME(6) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
|
||||||
|
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 二、补齐已有表的PDA字段及完工照片快照。
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认完成时间''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 三、退货箱来源和废弃状态约束(V4)。
|
||||||
|
ALTER TABLE ll_cargo_discard_item
|
||||||
|
MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID',
|
||||||
|
MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID',
|
||||||
|
MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID';
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'),
|
||||||
|
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))',
|
||||||
|
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''OUTBOUNDED'',''DISCARD_PENDING'') AND sku_types>0 AND total_quantity>0) OR (status IN(''MERGED'',''DISCARDED'') AND sku_types=0 AND total_quantity=0))');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'),
|
||||||
|
'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status, ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))',
|
||||||
|
'ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN(''AVAILABLE'',''PACKING_RESERVED'',''RELABEL_PENDING'',''OUTBOUND_PENDING'',''MERGED'',''OUTBOUNDED'',''DISCARD_PENDING'',''DISCARDED''))');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|
||||||
|
-- 四、通用库存、部分选箱、箱来源(V25)。
|
||||||
|
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
|
||||||
|
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|
||||||
|
-- 五、退货箱SKU归零约束(V26)。
|
||||||
|
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
|
||||||
|
-- 原子替换约束,可重复执行;不修改业务记录。
|
||||||
|
SET @ddl = IF(EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
|
||||||
|
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
|
||||||
|
),
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 六、只读核对。
|
||||||
|
SHOW CREATE TABLE `ll_cargo_discard_application`;
|
||||||
|
SHOW CREATE TABLE `ll_cargo_discard_item`;
|
||||||
|
SHOW CREATE TABLE `ll_cargo_discard_carton`;
|
||||||
|
SHOW CREATE TABLE `ll_cargo_discard_status_history`;
|
||||||
|
SHOW CREATE TABLE `ll_cargo_discard_photo`;
|
||||||
|
SHOW CREATE TABLE `ll_return_inventory_carton`;
|
||||||
|
SHOW CREATE TABLE `ll_inbound_plan_carton`;
|
||||||
|
SHOW CREATE TABLE ll_return_inventory_carton_item;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- 客户照片确认功能增量,MySQL 8.0.16+,可重复执行。
|
||||||
|
-- 完整执行,遇错停止;须连接同时承载这两个业务库的指定实例。
|
||||||
|
-- 仅新增确认时间和权限,不确认/回填任何历史业务单据。
|
||||||
|
USE `baiyue_lastleg`;
|
||||||
|
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
USE `baiyue_iam`;
|
||||||
|
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
|
||||||
|
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
|
||||||
|
-- 可重复执行;授权和持久化安全版本在同一事务提交。
|
||||||
|
START TRANSACTION;
|
||||||
|
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||||
|
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
|
||||||
|
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
|
||||||
|
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||||
|
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
|
||||||
|
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
|
||||||
|
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
|
||||||
|
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||||
|
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
|
||||||
|
JOIN iam_membership m ON m.user_id=u.id
|
||||||
|
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
|
||||||
|
JOIN iam_role r ON r.id=mr.role_id
|
||||||
|
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
|
||||||
|
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
|
||||||
|
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
|
||||||
|
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- 废弃明细新增字段专项补齐,MySQL 8.0.16+,可重复执行。
|
||||||
|
-- 仅 baiyue_lastleg;前提是 ll_cargo_discard_item 已存在。
|
||||||
|
-- 独立于退货库存约束,无需先执行其他汇总章节。
|
||||||
|
-- 不修改业务记录或库存数量;完整执行,遇错停止,DDL隐式提交。
|
||||||
|
USE `baiyue_lastleg`;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='completion_photo_file_ids'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN completion_photo_file_ids TEXT NULL COMMENT ''PDA完工照片文件ID JSON数组''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
|
||||||
|
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
-- 核对新增字段;应返回10行。
|
||||||
|
SELECT column_name, column_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item'
|
||||||
|
AND column_name IN ('actual_discard_packages','pda_status','pda_operator_id','pda_completed_at','completion_photo_file_ids','source_type','return_carton_id','inventory_position_id','carton_marks_json','carton_scan_required')
|
||||||
|
ORDER BY ordinal_position;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- MySQL 8.0.16+;对Lastleg连接的实例完整执行,遇错停止。DDL隐式提交。
|
||||||
|
USE `baiyue_lastleg`;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port;
|
||||||
|
|
||||||
|
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
|
||||||
|
-- 原子替换约束,可重复执行;不修改业务记录。
|
||||||
|
SET @ddl = IF(EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
|
||||||
|
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
|
||||||
|
),
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
|
||||||
|
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SHOW CREATE TABLE ll_return_inventory_carton_item;
|
||||||
@@ -4139,7 +4139,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
|
PREPARE return_inventory_carton_dimension_stmt FROM @ddl; EXECUTE return_inventory_carton_dimension_stmt; DEALLOCATE PREPARE return_inventory_carton_dimension_stmt;
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
|
||||||
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
|
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
||||||
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||||
|
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||||
|
|
||||||
|
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||||
|
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { randomInt, randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
|
||||||
|
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required')
|
||||||
|
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required')
|
||||||
|
const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}`
|
||||||
|
const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`)
|
||||||
|
const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] }
|
||||||
|
let admin, pda, userId
|
||||||
|
const ok = value => value.http === 200 && String(value.body?.code) === '200'
|
||||||
|
async function call(token, method, path, body, expectedFailure = false) {
|
||||||
|
const isForm = body instanceof FormData
|
||||||
|
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) })
|
||||||
|
let value; try { value = await response.json() } catch { value = null }
|
||||||
|
const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }
|
||||||
|
// Never persist auth bodies, credentials, customer profiles or tokens.
|
||||||
|
report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message })
|
||||||
|
if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`)
|
||||||
|
return expectedFailure ? result : value.data
|
||||||
|
}
|
||||||
|
function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) }
|
||||||
|
async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) }
|
||||||
|
try {
|
||||||
|
admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken
|
||||||
|
const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200')
|
||||||
|
const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID))
|
||||||
|
assert(customer?.id, 'Existing active local customer')
|
||||||
|
const customerId = customer.id
|
||||||
|
const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
|
||||||
|
const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0]
|
||||||
|
assert(warehouse?.id, 'Existing overseas warehouse')
|
||||||
|
report.customerId = customerId; report.warehouseId = warehouse.id
|
||||||
|
const roles = await call(admin,'GET','/api/platform/v1/iam/roles')
|
||||||
|
const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
|
||||||
|
assert(role?.id, 'Existing PDA role')
|
||||||
|
const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}`
|
||||||
|
const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD
|
||||||
|
if (!password) throw new Error('Explicit fixture PDA password required')
|
||||||
|
const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' })
|
||||||
|
userId = user.membershipId
|
||||||
|
pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken
|
||||||
|
assert(pda, 'Dedicated warehouse-bound PDA session')
|
||||||
|
await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 })
|
||||||
|
for (const scenario of [
|
||||||
|
{ name:'single-box', counts:[1], actual:1 },
|
||||||
|
{ name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] },
|
||||||
|
{ name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' },
|
||||||
|
{ name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' }
|
||||||
|
]) {
|
||||||
|
const key = `${marker}-${scenario.name}`
|
||||||
|
const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` }))
|
||||||
|
let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items })
|
||||||
|
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently')
|
||||||
|
const no = detail.transferNo
|
||||||
|
const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save()
|
||||||
|
detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key})
|
||||||
|
assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving')
|
||||||
|
const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}`
|
||||||
|
await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true)
|
||||||
|
detail = await call(pda,'GET',path)
|
||||||
|
const palletAssignments = [];
|
||||||
|
for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) {
|
||||||
|
detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`})
|
||||||
|
let pallet = detail.pallets.find(row=>row.status==='DRAFT')
|
||||||
|
assert(pallet?.id,'Saved pallet draft')
|
||||||
|
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`})
|
||||||
|
pallet=detail.pallets.find(row=>row.id===pallet.id)
|
||||||
|
detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo})
|
||||||
|
fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount })
|
||||||
|
}
|
||||||
|
let allocationIndex = 0;
|
||||||
|
const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id));
|
||||||
|
let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index }))
|
||||||
|
if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13})
|
||||||
|
const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null}
|
||||||
|
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true)
|
||||||
|
if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true)
|
||||||
|
await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true)
|
||||||
|
detail = await call(pda,'POST',`${path}/cartons/count`,count)
|
||||||
|
assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count')
|
||||||
|
assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units')
|
||||||
|
const counted = detail.cartons.filter(row=>row.status==='COUNTED')
|
||||||
|
for (const pallet of detail.pallets) {
|
||||||
|
const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id));
|
||||||
|
assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes');
|
||||||
|
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes');
|
||||||
|
assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes');
|
||||||
|
}
|
||||||
|
assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`)
|
||||||
|
fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity}))
|
||||||
|
assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained')
|
||||||
|
const replay = await call(pda,'POST',`${path}/cartons/count`,count)
|
||||||
|
assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts')
|
||||||
|
await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true)
|
||||||
|
const label = await call(pda,'GET',`${path}/carton-label`)
|
||||||
|
assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton')
|
||||||
|
const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`)
|
||||||
|
const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form)
|
||||||
|
const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]}
|
||||||
|
detail=await call(pda,'POST',`${path}/palletize`,complete)
|
||||||
|
assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation')
|
||||||
|
assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts')
|
||||||
|
await call(pda,'POST',`${path}/palletize`,complete)
|
||||||
|
const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`)
|
||||||
|
assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts')
|
||||||
|
assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted')
|
||||||
|
fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true
|
||||||
|
await save()
|
||||||
|
}
|
||||||
|
report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed)
|
||||||
|
} catch(error) { report.error=error.message; process.exitCode=1 }
|
||||||
|
finally {
|
||||||
|
if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} }
|
||||||
|
if(userId) report.fixturePdaMembershipId=userId
|
||||||
|
await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user