feat(phase4): automate overseas PDA inbound lifecycle
This commit is contained in:
@@ -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/**"]
|
||||
|
||||
+3
-1
@@ -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),
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+1
@@ -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());
|
||||
}
|
||||
|
||||
+15
-1
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user