feat(phase4): automate overseas PDA inbound lifecycle

This commit is contained in:
2026-08-24 15:38:28 +08:00
parent ef9f253f47
commit 6aca41427c
19 changed files with 377 additions and 9 deletions
@@ -16213,6 +16213,7 @@ VALUES
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
(5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0),
(5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0),
(5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0),
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
@@ -10,7 +10,7 @@ spring:
routes:
- id: lastleg-service
uri: lb://lastleg-service
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"]
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"]
- id: warehouse-overseas-service
uri: lb://warehouse-overseas-service
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class OverseasLastlegRouteContractTest {
@Test
void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException {
void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException {
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
@@ -22,6 +22,8 @@ class OverseasLastlegRouteContractTest {
assertThat(route).contains("uri: lb://lastleg-service")
.contains("/api/platform/v1/last-leg/**")
.contains("/api/merchant/v1/last-leg/**")
.contains("/api/pda/v1/last-leg/**")
.doesNotContain("StripPrefix", "RewritePath");
}
}
@@ -19,6 +19,7 @@ VALUES
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
(5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0),
(5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0),
(5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0),
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
@@ -39,6 +39,7 @@ class OverseasPhase4PermissionSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
Set<String> catalog = codes(sql);
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'");
assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
@@ -53,7 +54,7 @@ class OverseasPhase4PermissionSqlContractTest {
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8));
assertThat(codes(sql)).containsAll(moduleCatalog);
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission");
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'");
assertCustomAdminGrant(sql);
}
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
@Select("""
@@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper<InboundPlanCarton> {
WHERE l.plan_id=#{planId})
""")
boolean hasLabelPool(@Param("planId") Long planId);
@Select("""
SELECT DISTINCT c.* FROM ll_inbound_plan_carton c
JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id
JOIN ll_inbound_plan_line l ON l.order_id=d.order_id
WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id
ORDER BY c.id
""")
List<InboundPlanCarton> listExpected(@Param("planId") Long planId);
}
@@ -11,7 +11,8 @@ public final class PdaResponses {
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt,
List<Pallet> pallets, List<Allocation> allocations) {}
List<Pallet> pallets, List<Allocation> allocations,
List<ExpectedCarton> expectedCartons) {}
public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -24,6 +25,10 @@ public final class PdaResponses {
int countedPackageCount, int pickedPackageCount,
String status, int version) {}
public record ExpectedCarton(String cartonMark, String skuCode,
String destinationWarehouseId, String destinationWarehouseCode,
long planPalletId, String status) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
@@ -63,6 +63,7 @@ public class PdaWorkflowRepository {
public List<InboundPlanCarton> cartons(Long planId) {
return cartons.selectList(new LambdaQueryWrapper<InboundPlanCarton>().eq(InboundPlanCarton::getPlanId, planId));
}
public List<InboundPlanCarton> expectedCartons(Long planId) { return cartons.listExpected(planId); }
public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) {
return cartons.findPlanLine(planId, carton.getSourceInboundDetailId());
}
@@ -650,9 +650,23 @@ public class InboundPdaWorkflowService {
InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(),
InboundPdaWorkflowService.value(value.getVersion()));
}).toList();
List<PdaResponses.ExpectedCarton> expectedCartons = repository.expectedCartons(task.getPlanId()).stream()
.map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(),
value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(),
recommendedPallet(value, planPallets), value.getStatus()))
.toList();
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations);
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations,
expectedCartons);
}
private long recommendedPallet(InboundPlanCarton carton, List<InboundPlanPallet> pallets) {
if (carton.getPlanPalletId() != null) return carton.getPlanPalletId();
return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId())
|| (carton.getDestinationWarehouseId() == null
&& Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode())))
.map(InboundPlanPallet::getId).findFirst().orElse(0L);
}
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
@@ -224,7 +224,8 @@ public class InboundPlanService {
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
if (!("PENDING_ASSIGNMENT".equals(order.currentStatus())
|| "DESTINATION_ASSIGNED".equals(order.currentStatus()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
@@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -35,6 +37,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -88,8 +91,17 @@ public class LastlegOrderService {
detailUpdate.setDestinationWarehouseCode(request.warehouseCode());
detailUpdate.setDestinationWarehouseName(request.warehouseName());
detailMapper.update(detailUpdate, new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, order.getId()));
InboundPlanCarton cartonUpdate = new InboundPlanCarton();
cartonUpdate.setDestinationWarehouseId(request.warehouseId());
cartonUpdate.setDestinationWarehouseCode(request.warehouseCode());
cartonUpdate.setDestinationWarehouseName(request.warehouseName());
cartonMapper.update(cartonUpdate, new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, order.getCustomerId())
.eq(InboundPlanCarton::getOrderId, order.getId()));
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(order.getCustomerId());
history.setOrderId(order.getId());
@@ -101,6 +101,9 @@ class InboundPdaWorkflowServiceTest {
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
InboundPlanCarton expected = carton("BOX-INSTRUCTION", "EXPECTED", targetLine.getId(), "WH-A");
expected.setSkuCode("SKU-A");
when(repository.expectedCartons(plan.getId())).thenReturn(List.of(expected));
var result = service.task("100", "LIP1");
@@ -108,6 +111,9 @@ class InboundPdaWorkflowServiceTest {
assertEquals(target.getId(), result.allocations().get(0).allocationId());
assertEquals("RK-A", result.allocations().get(0).inboundCode());
assertEquals(3, result.allocations().get(0).plannedPackageCount());
assertEquals("BOX-INSTRUCTION", result.expectedCartons().get(0).cartonMark());
assertEquals("SKU-A", result.expectedCartons().get(0).skuCode());
assertEquals(targetPallet.getId(), result.expectedCartons().get(0).planPalletId());
}
@Test
@@ -95,6 +95,24 @@ class InboundPlanServiceTest {
assertEquals("CONT-1", result.containerNo());
}
@Test
void convertsAConsistentDestinationAssignedOrder() {
SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2",
"DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10,
"2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
assertEquals(2L, result.sourceOrderId());
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).insert(line.capture());
assertEquals("2090000000000000001", line.getValue().getDestinationWarehouseId());
verify(planMapper).insert(any(InboundPlan.class));
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -31,6 +33,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock InboundPlanCartonMapper cartons;
LastlegOrderService service;
@BeforeEach
@@ -39,6 +42,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
}
@Test
@@ -76,6 +80,10 @@ class LastlegOrderServiceTest {
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).update(cargo.capture(), any());
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<InboundPlanCarton> carton = ArgumentCaptor.forClass(InboundPlanCarton.class);
verify(cartons).update(carton.capture(), any());
assertEquals("wh-2", carton.getValue().getDestinationWarehouseId());
assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("100", history.getValue().getCustomerId());
@@ -93,6 +101,7 @@ class LastlegOrderServiceTest {
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
verify(details, never()).update(any(), any());
verify(cartons, never()).update(any(), any());
}
@Test
+26 -1
View File
@@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
export function normalizeModuleOrder(modules) {
const normalized = [...modules]
@@ -266,6 +266,31 @@ export class AcceptanceRun {
return this.requestWithBody(method, path, form, token, extraHeaders)
}
async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
}
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
try {
const response = await fetch(`${this.options.overseasGateway}${path}`, {
method, headers, body: form, signal: AbortSignal.timeout(60_000),
})
const content = await response.text(); let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
return { method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method, path, http: 0, body: null,
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
+3 -2
View File
@@ -55,6 +55,7 @@ export async function run(context) {
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` |
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
@@ -62,10 +63,10 @@ export async function run(context) {
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;船节点禁止手工创建任务。所有模块继续禁止调用海外出库、派送、PDA 或内部接口。开发环境定向执行示例:
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。
```powershell
node scripts/admin-test-env-acceptance.mjs --gateway http://8.163.49.32:9000 --overseas-gateway http://8.163.49.32:9300 --modules phase4-overseas-warehouse,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
```
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-pda.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
test('PDA module uses only injected development acceptance requests', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /context\.requestOverseas\(/)
assert.match(source, /context\.requestOverseasMultipart\(/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /process\.env|fetch\(/)
})
test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => {
for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/',
'/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) {
assert.ok(source.includes(token), token)
}
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
assert.match(source, /PENDING_ASSIGNMENT/)
assert.match(source, /expectedCartons/)
})
test('missing source order is reported as a fixture prerequisite without business writes', async () => {
const calls = []; const gaps = []; const steps = []
const context = {
marker: 'PDA-CONTRACT', createdResources: {},
async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } },
async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') },
addStep(...args) { steps.push(args) },
assert(...args) { steps.push(args) },
addCoverageGap(...args) { gaps.push(args) },
}
await run(context)
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'GET')
assert.equal(gaps.length, 1)
assert.match(gaps[0][3], /三期装柜完成事件链/)
assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null)
})
@@ -0,0 +1,210 @@
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function query(path, params) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
return `${path}?${search}`
}
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
async function domesticGet(context, name, path) {
const response = await context.request('GET', path)
const passed = success(response)
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return passed ? response.body.data : null
}
async function overseas(context, method, name, path, body = undefined) {
const response = await context.requestOverseas(method, path, body)
const passed = success(response)
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body.data : null }
}
async function findUnplannedOrder(context) {
const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE'))
for (const tenant of tenants) {
const customerId = id(tenant?.id)
if (!longId(customerId)) continue
const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 }))
if (!success(plans.response)) continue
const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean))
const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 }))
if (!success(orders.response)) continue
const order = records(orders.data).find((item) => longId(item?.id) && !planned.has(id(item.id)))
if (order) return { customerId, order }
}
return null
}
async function createWarehouse(context, customerId) {
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const warehouseCode = `PDA-${suffix}`.slice(0, 50)
const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', {
customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS',
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA',
contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`,
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null,
latitude: null, longitude: null },
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null,
description: `${context.marker} PDA inbound acceptance` }],
})
const warehouseId = id(created.data)
context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId),
`warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS)
return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` }
}
async function createResource(context, warehouseId, index, fbaWarehouseCode) {
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase()
const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`,
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker,
})
const locationId = id(location.data)
context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId),
`locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS)
const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`,
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2,
unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined,
remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null,
})
const palletId = id(pallet.data)
context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId),
`palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS)
return { locationId, palletId }
}
async function operateExpectedCartons(context, customerId, planNo, task) {
for (const [index, carton] of (task.expectedCartons || []).entries()) {
if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue
const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), {
cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId),
idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response,
scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark,
`cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS)
}
}
async function operateLegacyAllocations(context, customerId, planNo, task) {
for (const [index, allocation] of (task.allocations || []).entries()) {
const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0)
if (remaining <= 0) continue
await overseas(context, 'POST', `清点入仓分配${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), {
packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)),
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
await overseas(context, 'POST', `实收入仓分配${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), {
packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)),
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
}
}
export async function run(context) {
context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null,
planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true }
const source = await findUnplannedOrder(context)
if (!source) {
context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单',
'所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable')
return
}
const { customerId, order } = source
const warehouse = await createWarehouse(context, customerId)
if (!longId(warehouse.warehouseId)) return
Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId })
const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓',
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), {
warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode,
warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`,
})
if (!success(transferred.response)) return
const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', {
orderId: id(order.id), customerId,
})
const plan = converted.data
const planNo = plan?.planNo || ''
context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response,
Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0,
`planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS)
if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return
context.createdResources.phase4OverseasPda.planNo = planNo
for (const [index, planPallet] of plan.pallets.entries()) {
const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode)
if (!longId(resource.locationId) || !longId(resource.palletId)) return
const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`,
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), {
warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId,
idempotencyKey: key(context, 'assign', String(index + 1)),
})
if (!success(assigned.response)) return
}
const pushed = await overseas(context, 'POST', '推送PDA入仓任务',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), {
idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
const task = pushed.data
context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response,
longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length
&& Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons),
`taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS)
if (!longId(task?.taskId)) return
context.createdResources.phase4OverseasPda.taskId = id(task.taskId)
if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task)
else await operateLegacyAllocations(context, customerId, planNo, task)
const photo = await context.requestOverseasMultipart('POST',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), {
name: `${context.marker}-unload.png`, type: 'image/png',
content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')),
})
context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS)
const fileId = id(photo?.body?.data?.fileId)
context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS)
if (!longId(fileId)) return
const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), {
fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
})
context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response,
bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt),
`fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS)
const completed = await overseas(context, 'POST', '完成PDA入仓并写库存',
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId }))
context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response,
completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt),
`status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态',
query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId }))
context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED',
`planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS)
const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存',
query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 }))
context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response,
records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId),
`balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS)
}
@@ -9,6 +9,7 @@ const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'u
test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => {
for (const module of [
'phase4-overseas-warehouse',
'phase4-overseas-pda',
'phase4-overseas-business',
'phase4-overseas-master-data',
'phase4-carrier-nodes',
@@ -28,5 +29,6 @@ test('entry accepts a separate overseas Gateway without exposing token state', (
assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/)
assert.match(source, /async requestOverseas\(/)
assert.match(source, /async requestOverseasBinary\(/)
assert.match(source, /async requestOverseasMultipart\(/)
assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/)
})