fix: align HTTP acceptance fixtures and pallet permissions
This commit is contained in:
@@ -1799,6 +1799,9 @@ 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_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); 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_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||||
|
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||||
|
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||||
|
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
@@ -2564,6 +2567,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
|||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||||
|
new_carton_mark VARCHAR(255) 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', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) 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', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||||
@@ -4589,6 +4593,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
|||||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) 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', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||||
source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号',
|
source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号',
|
||||||
fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛',
|
fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛',
|
||||||
|
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码',
|
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码',
|
||||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
||||||
relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID',
|
relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID',
|
||||||
|
|||||||
+2
-2
@@ -142,7 +142,7 @@ public class PlatformDropshipPalletizationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
||||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||||
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long inboundId,
|
@PathVariable @Positive long inboundId,
|
||||||
@RequestParam @Positive long accountId) {
|
@RequestParam @Positive long accountId) {
|
||||||
@@ -151,7 +151,7 @@ public class PlatformDropshipPalletizationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
||||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||||
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long inboundId,
|
@PathVariable @Positive long inboundId,
|
||||||
@PathVariable @Positive long palletId,
|
@PathVariable @Positive long palletId,
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ paths:
|
|||||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
|
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
|
||||||
get:
|
get:
|
||||||
summary: Admin 查询入仓单权威托盘结果
|
summary: Admin 查询入仓单权威托盘结果
|
||||||
x-required-authority: firstleg:inbound:list
|
x-required-authority: platform:firstleg:inbound:list
|
||||||
responses: {'200': {description: OK}}
|
responses: {'200': {description: OK}}
|
||||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
||||||
get:
|
get:
|
||||||
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
|
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
|
||||||
x-required-authority: firstleg:inbound:list
|
x-required-authority: platform:firstleg:inbound:list
|
||||||
responses: {'200': {description: PDF}}
|
responses: {'200': {description: PDF}}
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
+15
@@ -12,6 +12,21 @@ import java.nio.file.Path;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class DropshipPalletizationSqlContractTest {
|
class DropshipPalletizationSqlContractTest {
|
||||||
|
@Test
|
||||||
|
void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception {
|
||||||
|
var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class;
|
||||||
|
for (var method : controller.getDeclaredMethods()) {
|
||||||
|
if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")) {
|
||||||
|
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
|
||||||
|
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list")
|
||||||
|
.doesNotContain("x-required-authority: firstleg:inbound:list");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception {
|
void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception {
|
||||||
String sql = DropshipPalletizationMapper.class
|
String sql = DropshipPalletizationMapper.class
|
||||||
|
|||||||
@@ -1706,6 +1706,9 @@ 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_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); 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_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||||
|
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||||
|
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||||
|
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
@@ -2471,6 +2474,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
|||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||||
|
new_carton_mark VARCHAR(255) 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', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) 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', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||||
|
|||||||
+22
@@ -12,6 +12,28 @@ import java.util.Map;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class ReturnCartonQueryTest {
|
class ReturnCartonQueryTest {
|
||||||
|
@Test
|
||||||
|
void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception {
|
||||||
|
var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||||
|
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||||
|
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"),
|
||||||
|
com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class);
|
||||||
|
assertThat(table.getAllSqlSelect()).contains("new_carton_mark");
|
||||||
|
for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"),
|
||||||
|
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
|
||||||
|
String sql = java.nio.file.Files.readString(path);
|
||||||
|
var matcher = java.util.regex.Pattern.compile(
|
||||||
|
"CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE")
|
||||||
|
.matcher(sql);
|
||||||
|
int definitions = 0;
|
||||||
|
while (matcher.find()) {
|
||||||
|
definitions++;
|
||||||
|
assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)");
|
||||||
|
}
|
||||||
|
assertThat(definitions).isPositive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
|
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
|
||||||
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
|
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export async function run(context) {
|
|||||||
|
|
||||||
## 可用上下文
|
## 可用上下文
|
||||||
|
|
||||||
|
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||||
|
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||||
|
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||||
|
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
|
||||||
|
|
||||||
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
||||||
|
|
||||||
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ test('confirmed package count reconciles an uncertain transport result before re
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function exerciseSystemPalletScenario(fault = '') {
|
async function exerciseSystemPalletScenario(fault = '') {
|
||||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
|
const task = { taskId: '81', inboundId: '82', warehouseId: '83', tenantId: '85', generatedPalletCount: 1, palletSetupReady: true, taskStatus: 'CLAIMED',
|
||||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
|
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
|
||||||
const seenCounts = new Map()
|
const seenCounts = new Map()
|
||||||
const seenCreates = new Set()
|
const seenCreates = new Set()
|
||||||
@@ -138,7 +138,9 @@ async function exerciseSystemPalletScenario(fault = '') {
|
|||||||
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
||||||
async request(method, path, body) {
|
async request(method, path, body) {
|
||||||
if (path.includes('/pending?')) return response([task])
|
if (path.includes('/pending?')) return response([task])
|
||||||
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
|
if (path.endsWith('/claim')) return response({ ...task, pallets: [] })
|
||||||
|
if (path === '/api/platform/v1/first-leg/pda-dropship/inbound-orders/82/pallets?accountId=85') return response(task.pallets)
|
||||||
|
if (method === 'GET' && path.includes('/tasks/81?')) return response(task)
|
||||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
|
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
|
||||||
if (path.endsWith('/cartons/count')) {
|
if (path.endsWith('/cartons/count')) {
|
||||||
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
||||||
|
|||||||
@@ -112,12 +112,20 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
|||||||
const totalCartons = Number(measurement?.actualPackageCount)
|
const totalCartons = Number(measurement?.actualPackageCount)
|
||||||
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
||||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||||
const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : []
|
if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null
|
||||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse,
|
// Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets.
|
||||||
generatedPallets.length === expectedPalletCount && generatedPallets.every((item) =>
|
const palletResponse = await context.request('GET',
|
||||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')),
|
`/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`)
|
||||||
|
const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS)
|
||||||
|
const generatedPallets = Array.isArray(palletData) ? palletData : []
|
||||||
|
const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount
|
||||||
|
&& claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount
|
||||||
|
&& generatedPallets.every((item) =>
|
||||||
|
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || ''))
|
||||||
|
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse,
|
||||||
|
palletSetupValid,
|
||||||
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
||||||
if (!generatedPallets.length) return null
|
if (!palletSetupValid) return null
|
||||||
|
|
||||||
let countedCartons = 0
|
let countedCartons = 0
|
||||||
const sealedCartonMarks = []
|
const sealedCartonMarks = []
|
||||||
@@ -189,13 +197,13 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
|||||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||||
&& bound?.pallets?.length === expectedPalletCount,
|
&& bound?.pallets?.length === 1,
|
||||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||||
const replayBindResponse = await context.request('POST',
|
const replayBindResponse = await context.request('POST',
|
||||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||||
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
||||||
replayBind?.pallets?.length === expectedPalletCount
|
replayBind?.pallets?.length === bound?.pallets?.length
|
||||||
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
||||||
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { DOCUMENT_TYPES } from './phase3-container-documents.mjs'
|
import { DOCUMENT_TYPES, applicableDocumentTypes } from './phase3-container-documents.mjs'
|
||||||
|
|
||||||
|
test('rail excludes DO but truck still requires it; missing channel fails closed', () => {
|
||||||
|
for (const code of ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) {
|
||||||
|
assert.deepEqual(applicableDocumentTypes(code), ['DESTINATION_CLEARANCE'])
|
||||||
|
}
|
||||||
|
assert.deepEqual(applicableDocumentTypes('EXX_TRUCK'), DOCUMENT_TYPES)
|
||||||
|
assert.throws(() => applicableDocumentTypes(null), /权威渠道/)
|
||||||
|
})
|
||||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||||
|
|
||||||
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export const DOCUMENT_TYPES = Object.freeze([
|
|||||||
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER',
|
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export function applicableDocumentTypes(channelCode) {
|
||||||
|
if (!String(channelCode || '').trim()) throw new Error('货柜缺少权威渠道,不能判断DO适用性')
|
||||||
|
const seaRail = ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']
|
||||||
|
.includes(String(channelCode).trim().toUpperCase())
|
||||||
|
return seaRail ? ['DESTINATION_CLEARANCE'] : [...DOCUMENT_TYPES]
|
||||||
|
}
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||||
@@ -115,6 +122,13 @@ export async function run(context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`)
|
||||||
|
const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS)
|
||||||
|
if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) {
|
||||||
|
context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expectedTypes = applicableDocumentTypes(detail.container.channelCode)
|
||||||
const specs = [
|
const specs = [
|
||||||
{
|
{
|
||||||
type: 'DESTINATION_CLEARANCE',
|
type: 'DESTINATION_CLEARANCE',
|
||||||
@@ -122,7 +136,7 @@ export async function run(context) {
|
|||||||
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
||||||
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
||||||
},
|
},
|
||||||
...['DELIVERY_ORDER'].map((type) => ({
|
...expectedTypes.filter((type) => type === 'DELIVERY_ORDER').map((type) => ({
|
||||||
type,
|
type,
|
||||||
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
||||||
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
||||||
@@ -131,17 +145,28 @@ export async function run(context) {
|
|||||||
|
|
||||||
const documents = []
|
const documents = []
|
||||||
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
||||||
|
let applicabilityPassed = true
|
||||||
|
if (!expectedTypes.includes('DELIVERY_ORDER')) {
|
||||||
|
const rejected = await upload(context, `${CONTAINERS}/${containerId}/documents/DELIVERY_ORDER/upload`,
|
||||||
|
'DELIVERY_ORDER', 0, `${context.marker} 海铁DO负向验收`, key(context, 'rail-do-rejected'))
|
||||||
|
applicabilityPassed = conflict(rejected) && traced(rejected)
|
||||||
|
&& rejected.body?.message === '海铁渠道不适用DO资料'
|
||||||
|
context.addStep(MODULE, '海铁DO上传按渠道规则拒绝', rejected, applicabilityPassed,
|
||||||
|
`code=${codeOf(rejected)}, requestId=${rejected.requestId}, traceId=${rejected.traceId}`, OPTIONS)
|
||||||
|
}
|
||||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
||||||
const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS)
|
const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS)
|
||||||
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
||||||
|
const typesValid = expectedTypes.every((type) => storedTypes.has(type))
|
||||||
|
&& (expectedTypes.includes('DELIVERY_ORDER') || !storedTypes.has('DELIVERY_ORDER'))
|
||||||
context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse,
|
context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse,
|
||||||
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
||||||
&& DOCUMENT_TYPES.every((type) => storedTypes.has(type)),
|
&& typesValid && applicabilityPassed,
|
||||||
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
||||||
if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) {
|
if (id(overview?.container?.id) === containerId && documents.every(Boolean) && typesValid && applicabilityPassed) {
|
||||||
context.createdResources.phase3ContainerDocuments = {
|
context.createdResources.phase3ContainerDocuments = {
|
||||||
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
||||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES],
|
warehouseId, finalStatus: 'COMPLETED', documentTypes: expectedTypes,
|
||||||
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import path from 'node:path'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs'
|
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs'
|
||||||
|
|
||||||
|
test('one physical container is received once even when matrix roles overlap', () => {
|
||||||
|
const fba = { containerNo: 'TEST-C1', fulfillmentType: 'STANDARD' }
|
||||||
|
const overseas = { containerNo: 'TEST-C2' }
|
||||||
|
assert.deepEqual([null, fba, { ...fba }, overseas].filter(uniqueContainerProducer), [fba, overseas])
|
||||||
|
assert.doesNotMatch(source, /dropship \|\| matrix/)
|
||||||
|
})
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
|
||||||
|
|||||||
@@ -5,7 +5,26 @@ import test from 'node:test'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||||
import { run } from './phase4-overseas-pda-operations.mjs'
|
import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs'
|
||||||
|
|
||||||
|
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
|
||||||
|
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
|
||||||
|
{ positionId: '10', palletNo: 'REAL-A', availablePackages: 2 },
|
||||||
|
{ positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] }
|
||||||
|
const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [
|
||||||
|
{ positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] }
|
||||||
|
const selected = selectPhysicalRelabelSources([b, a, a], 'A')
|
||||||
|
assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B'])
|
||||||
|
assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exception uses the untouched third carton, independent of response ordering', () => {
|
||||||
|
const third = { itemId: '3', expectedPackages: 1 }
|
||||||
|
const detail = { items: [third, { itemId: '2' }, { itemId: '1' }] }
|
||||||
|
assert.equal(remainingRelabelItem(detail, [1, '2']), third)
|
||||||
|
assert.equal(remainingRelabelItem(detail, ['1', '2', '3']), null)
|
||||||
|
assert.equal(remainingRelabelItem(null, []), null)
|
||||||
|
})
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function expectConflict(context, token, method, name, path, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
|
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
|
||||||
return request(context, token, 'POST', name,
|
return request(context, token, 'POST', `${name}(${palletCode})`,
|
||||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +103,11 @@ async function resolvePublicLocation(context, fixture) {
|
|||||||
? { locationId: id(location.id), locationCode: location.locationCode } : null
|
? { locationId: id(location.id), locationCode: location.locationCode } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function remainingRelabelItem(detail, usedItemIds) {
|
||||||
|
const used = new Set(usedItemIds.map(id))
|
||||||
|
return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || null
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
|
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
|
||||||
for (const candidate of fixture.auxiliaryPallets || []) {
|
for (const candidate of fixture.auxiliaryPallets || []) {
|
||||||
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
|
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
|
||||||
@@ -328,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) {
|
|||||||
return success(unbound.response)
|
return success(unbound.response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectPhysicalRelabelSources(candidates, preferredBalanceId) {
|
||||||
|
const ordered = [...candidates].sort((a, b) =>
|
||||||
|
Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId)))
|
||||||
|
const sourceLines = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const source of ordered) {
|
||||||
|
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||||
|
for (const position of source.positions || []) {
|
||||||
|
const positionId = id(position?.positionId)
|
||||||
|
if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo)
|
||||||
|
|| position.palletNo.toUpperCase() === 'UNBOUND') continue
|
||||||
|
seen.add(positionId)
|
||||||
|
const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages))
|
||||||
|
if (!Number.isInteger(selected) || selected <= 0) continue
|
||||||
|
for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 })
|
||||||
|
if (sourceLines.length === 3) return sourceLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceLines
|
||||||
|
}
|
||||||
|
|
||||||
async function verifyRelabel(context, token, merchantToken, fixture) {
|
async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||||
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
|
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
|
||||||
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|
||||||
@@ -338,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!await fundRelabelWallet(context, customerId)) return false
|
if (!await fundRelabelWallet(context, customerId)) return false
|
||||||
const inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
// P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets,
|
||||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
// and may combine only the current completed container's same-destination inbounds.
|
||||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode,
|
const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || [])
|
||||||
}), undefined, {}, merchantToken)
|
.filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType
|
||||||
const inventoryCandidates = success(inventoryCandidatesResponse)
|
&& item.fbaWarehouseCode === fixture.fbaWarehouseCode)
|
||||||
&& Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : []
|
.map((item) => item.inboundCode).filter(nonBlank))]
|
||||||
const orderedCandidates = [...inventoryCandidates].sort((left, right) => {
|
const inventoryCandidates = []
|
||||||
const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
let inventoryCandidatesResponse = null
|
||||||
const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
for (const inboundCode of inboundCodes) {
|
||||||
return rightPreferred - leftPreferred
|
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||||
})
|
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||||
const sourceLines = []
|
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||||
let requiredPackages = 3
|
}), undefined, {}, merchantToken)
|
||||||
for (const source of orderedCandidates) {
|
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||||
for (const position of source?.positions || []) {
|
if (!success(inventoryCandidatesResponse)) return false
|
||||||
const available = Number(position?.availablePackages)
|
inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) =>
|
||||||
if (!id(position?.positionId) || available <= 0) continue
|
item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId)))
|
||||||
const selected = Math.min(requiredPackages, available)
|
|
||||||
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
|
|
||||||
sourceLines.push({ source, position, packageCount: 1 })
|
|
||||||
}
|
|
||||||
requiredPackages -= selected
|
|
||||||
if (requiredPackages === 0) break
|
|
||||||
}
|
|
||||||
if (requiredPackages === 0) break
|
|
||||||
}
|
}
|
||||||
|
const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId)
|
||||||
|
const requiredPackages = 3 - sourceLines.length
|
||||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||||
requiredPackages === 0,
|
requiredPackages === 0,
|
||||||
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
||||||
@@ -452,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
|||||||
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
|
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
|
||||||
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
|
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
|
||||||
detail = scanned.data
|
detail = scanned.data
|
||||||
|
if (!detail) return false
|
||||||
const skipItem = Array.isArray(detail?.items)
|
const skipItem = Array.isArray(detail?.items)
|
||||||
? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null
|
? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null
|
||||||
|
if (!skipItem) {
|
||||||
|
blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response)
|
||||||
|
return false
|
||||||
|
}
|
||||||
const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark,
|
const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark,
|
||||||
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
|
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
|
||||||
idempotencyKey: key(context, 'RELABEL-SKIP') }
|
idempotencyKey: key(context, 'RELABEL-SKIP') }
|
||||||
@@ -462,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
|||||||
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
|
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
|
||||||
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
|
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
|
||||||
detail = skipped.data
|
detail = skipped.data
|
||||||
const exceptionItem = item
|
if (!detail) return false
|
||||||
|
const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId])
|
||||||
|
if (!exceptionItem) {
|
||||||
|
blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response)
|
||||||
|
return false
|
||||||
|
}
|
||||||
const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null,
|
const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null,
|
||||||
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
|
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
|
||||||
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
|
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
|
||||||
@@ -473,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
|||||||
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
|
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
|
||||||
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
|
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
|
||||||
detail = exception.data
|
detail = exception.data
|
||||||
|
if (!detail) return false
|
||||||
|
|
||||||
const photo = await context.requestOverseasMultipart('POST',
|
const photo = await context.requestOverseasMultipart('POST',
|
||||||
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC
|
|||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
||||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||||
|
export function uniqueContainerProducer(item, index, all) {
|
||||||
|
return nonBlank(item?.containerNo)
|
||||||
|
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
||||||
|
}
|
||||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||||
function code(response) { return String(response?.body?.code ?? '') }
|
function code(response) { return String(response?.body?.code ?? '') }
|
||||||
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
||||||
@@ -897,9 +901,9 @@ export async function run(context) {
|
|||||||
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||||
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
||||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba,
|
const producers = [dropship, standardFba,
|
||||||
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||||
.filter((item) => nonBlank(item?.containerNo))
|
.filter(uniqueContainerProducer)
|
||||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||||
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
||||||
|
|||||||
Reference in New Issue
Block a user