From e11a528dfe9cf9fcc0611019a8dd74e9945a061b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:07:17 +0800 Subject: [PATCH] test(phase4): provision overseas lifecycle fixtures --- .../RelabelApplicationController.java | 9 +- .../lastleg/relabel/dto/RelabelRequests.java | 1 + .../openapi/overseas-business-v1.yaml | 3 +- .../RelabelApplicationControllerTest.java | 53 +++++++++++ .../RelabelApplicationServiceTest.java | 2 +- .../TaillegRealConsumerAcceptanceTest.java | 2 +- scripts/admin-test-env-acceptance.mjs | 8 +- .../merchant-qualification-fixture.mjs | 2 +- ...phase4-overseas-business-contract.test.mjs | 57 ++++++----- .../phase4-overseas-business.mjs | 95 +++++++++++++++++-- ...hase4-overseas-warehouse-contract.test.mjs | 15 +-- .../phase4-overseas-warehouse.mjs | 49 +++++++++- 12 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 4807f5419..2638bbc2e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -27,7 +27,7 @@ public class RelabelApplicationController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody RelabelRequests.Create request) { requirePlatform(user); - return ApiResponse.success(service.create("0", operator(user), request)); + return ApiResponse.success(service.create(requiredCustomer(request.customerId()), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") @@ -60,5 +60,12 @@ public class RelabelApplicationController { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } } + private String requiredCustomer(String customerId) { + String value = customerId == null ? null : customerId.trim(); + if (value == null || value.isEmpty() || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 32ddaea7c..60b8cdc78 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -16,6 +16,7 @@ public final class RelabelRequests { private RelabelRequests() {} public record Create( + @Size(max = 32) String customerId, @NotBlank @Size(max = 128) String customerOrderNo, @NotBlank @Size(max = 128) String overseasWarehouseId, @NotNull LocalDateTime labelDeadline, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6cf76b0a9..62a66330b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -466,8 +466,9 @@ components: RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelCreate: type: object - required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerId, customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: + customerId: { type: string, maxLength: 32, description: Target customer ID required by the platform create endpoint } customerOrderNo: { type: string, maxLength: 128 } overseasWarehouseId: { type: string, maxLength: 128 } labelDeadline: { type: string, format: date-time } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java new file mode 100644 index 000000000..7e75808d2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RelabelApplicationControllerTest { + private final RelabelApplicationService service = mock(RelabelApplicationService.class); + private final RelabelApplicationController controller = controller(); + + @Test + void platformCreateUsesExplicitTargetCustomer() { + RelabelRequests.Create request = request("190000000000000001"); + + controller.create(platform(), request); + + verify(service).create("190000000000000001", "7", request); + } + + @Test + void platformCreateRejectsMissingOrPlatformCustomer() { + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request(null))); + assertThrows(ResponseStatusException.class, () -> controller.create(platform(), request("0"))); + } + + private RelabelRequests.Create request(String customerId) { + return new RelabelRequests.Create(customerId, "CUSTOMER-1", "WH-1", + LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1", + List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1, + "SHIP-1", null, "SKU-B", "Replace", null, null, null))); + } + + private AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); + } + + private RelabelApplicationController controller() { + RelabelApplicationController value = new RelabelApplicationController(); + ReflectionTestUtils.setField(value, "service", service); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 97a8dad24..a4262de7f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -212,7 +212,7 @@ class RelabelApplicationServiceTest { } private static RelabelRequests.Create request(String key, int total, int notified) { - return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + return new RelabelRequests.Create(null, "CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index ff1840e5f..15a3694c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -92,7 +92,7 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) { relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create( - MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", + null, MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null, null, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW", "换标验收", null, null, null)))); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ed0d490b2..13035edfa 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -61,6 +61,10 @@ export function normalizeModuleOrder(modules) { normalized.splice(normalized.indexOf('phase3-loading-finalize') + 1, 0, 'phase3-order-splitting') } } + if (normalized.includes('phase4-overseas-business') + && !normalized.includes('merchant-qualification-fixture')) { + normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') + } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) if (firstFinanceIndex >= 0) { @@ -229,9 +233,9 @@ export class AcceptanceRun { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } - async requestOverseas(method, path, body = undefined, extraHeaders = {}) { + async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) { const headers = { Accept: 'application/json', ...extraHeaders } - if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` + if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' try { const response = await fetch(`${this.options.overseasGateway}${path}`, { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index e860d1591..6e586c0e5 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -82,6 +82,6 @@ export async function run(context) { process.env.MERCHANT_TEST_USERNAME = username process.env.MERCHANT_TEST_PASSWORD = password - context.context.merchantQualificationFixture = { tenantId, username, marker: context.marker } + context.context.merchantQualificationFixture = { tenantId, username, password, marker: context.marker } context.createdResources.merchantQualificationFixture = { tenantId, username, marker: context.marker } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 1d2af9827..3e41dea1b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -10,7 +10,7 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } -function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { +function fixture() { const calls = []; const steps = []; const gaps = [] const state = { truck: null, @@ -19,16 +19,30 @@ function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { } const marker = 'HTTP-20260821-001' const context = { - marker, calls, steps, gaps, context: {}, - createdResources: ownedPurchase ? { overseasProcurement: { marker, orderNo: 'OP1', customerId: '10001' } } : {}, - async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body }); return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) }, - async requestOverseas(method, requestPath, body) { + marker, calls, steps, gaps, + context: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', password: 'secret' } }, + createdResources: { merchantQualificationFixture: { tenantId: '10001', username: 'merchant', marker } }, + async request(method, requestPath, body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.includes('/iam/tenants?')) return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) + if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`unexpected domestic ${method} ${requestPath}`) + }, + async requestOverseas(method, requestPath, body, headers, token) { calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') { + assert.equal(token, 'merchant-overseas-token') + state.purchase = { ...state.purchase, customerName: body.customerName, remark: body.remark } + return response({ summary: state.purchase }) + } if (requestPath.includes('/customer-options')) return response([{ customerId: '10001', customerName: '客户甲' }]) if (requestPath.includes('/warehouse-options')) return response([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) - if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(ownedPurchase || historicalPurchase ? [state.purchase] : [])) + if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page([state.purchase])) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response({ summary: state.purchase, remark: marker }) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { state.purchase = { ...state.purchase, ...body, version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } + if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { state.purchase = { ...state.purchase, status: 'REJECTED', version: state.purchase.version + 1 }; return response({ summary: state.purchase }) } if (requestPath.includes('/us-truck-orders?') && method === 'POST') { state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }; return response({ summary: state.truck }) } if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 }; return response({ summary: state.truck }) } if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response({ summary: state.truck }) @@ -50,7 +64,7 @@ function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) { test('module is context injected and excludes forbidden business surfaces', () => { assert.match(source, /export async function run\(context\)/) assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) - for (const forbidden of ['/merchant/', '/review', '/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) + for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden) assert.doesNotMatch(source, /requestOverseas\('DELETE'/) }) @@ -58,14 +72,13 @@ test('marker truck and looseboard cover create detail edit stale conflict and fi const context = fixture() await run(context) assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) - assert.deepEqual(context.calls.filter((call) => call.method === 'POST').map((call) => call.path), [ - '/api/platform/v1/last-leg/us-truck-orders?customerId=10001', - '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001', - ]) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/merchant/v1/last-leg/overseas-procurements')) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/us-truck-orders?customerId=10001')) + assert.ok(context.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001')) assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/us-truck-orders/')).length, 3) assert.equal(context.calls.filter((call) => call.method === 'PUT' && call.path.includes('/local-looseboard-orders/')).length, 2) assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/local-looseboard-orders/')).length, 1) - const statusCalls = context.calls.filter((call) => call.path.includes('/status?')) + const statusCalls = context.calls.filter((call) => call.channel === 'overseas' && call.path.includes('/status?')) assert.deepEqual(statusCalls.map((call) => [call.method, call.path, call.body.status]), [ ['PATCH', '/api/platform/v1/last-leg/us-truck-orders/TK1/status?customerId=10001', 'CANCELLED'], ['PATCH', '/api/platform/v1/last-leg/local-looseboard-orders/LB1/status?customerId=10001', 'CANCELLED'], @@ -81,24 +94,20 @@ test('marker truck and looseboard cover create detail edit stale conflict and fi assert.ok(context.gaps.every((gap) => !/requestId=missing|traceId=missing/.test(gap[3]))) }) -test('historical procurement data is never patched without marker ownership', async () => { - const context = fixture({ historicalPurchase: true }) - await run(context) - assert.equal(context.calls.some((call) => call.method === 'PATCH' && call.path.includes('/overseas-procurements/')), false) - assert.equal(context.gaps.some((gap) => gap[4] === 'missing-marker-owned-procurement'), true) -}) - -test('marker-owned procurement may be patched but never created, reviewed or shipped', async () => { - const context = fixture({ ownedPurchase: true }) +test('marker procurement is created through merchant then edited and rejected by platform', async () => { + const context = fixture() await run(context) const purchaseWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') - assert.deepEqual(purchaseWrites.map((call) => call.method), ['PATCH']) - assert.match(purchaseWrites[0].body.productSpecification, new RegExp(context.marker)) + assert.deepEqual(purchaseWrites.map((call) => call.method), ['POST', 'PATCH', 'POST']) + assert.match(purchaseWrites[1].body.productSpecification, new RegExp(context.marker)) assert.equal(context.createdResources.phase4OverseasBusiness.purchase.marker, context.marker) + assert.equal(context.createdResources.phase4OverseasBusiness.purchase.finalStatus, 'REJECTED') + assert.equal(context.createdResources.phase4OverseasBusiness.purchase.retained, false) }) test('contract locks real candidates, idempotency, version conflicts and trace evidence', () => { for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', '/overseas-procurements/customer-options?limit=200', - '/overseas-procurements/warehouse-options', 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', + '/overseas-procurements/warehouse-options', '/api/merchant/v1/last-leg/overseas-procurements', + "decision: 'REJECT'", 'idempotencyKey', 'phase4-truck', 'phase4-looseboard', '旧 version', '/status', "status: 'CANCELLED'", 'finally', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index c07bcf429..21fab5d74 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -20,6 +20,11 @@ async function domesticGet(context, name, path) { const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } +async function domestic(context, method, name, path, body) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} async function overseas(context, method, name, path, body) { const response = await context.requestOverseas(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) @@ -38,6 +43,11 @@ async function prerequisites(context) { coverageGap(context, '海外业务写验收 ACTIVE 客户前置', `IAM 未返回真实 ACTIVE 客户;${trace(tenants.response)}`, 'missing-active-customer') return null } + const fixture = context.createdResources?.merchantQualificationFixture + const fixtureCustomerId = id(fixture?.tenantId) + const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) || activeCustomers[0] + const partnerWarehouse = await createPartnerWarehouse(context) + const purchase = await createMerchantProcurement(context, activeCustomer) const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`) const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`) context.assert(MODULE, '采购客户候选结构正确', customerOptions.response, @@ -50,9 +60,62 @@ async function prerequisites(context) { const procurementCustomer = Array.isArray(customerOptions.data) ? customerOptions.data.find((item) => activeIds.has(id(item?.customerId))) : null const warehouse = Array.isArray(warehouseOptions.data) ? warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null - return { activeCustomer: activeCustomers[0], procurementCustomer, warehouse, customerOptions, warehouseOptions } + return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions, partnerWarehouse, purchase } } +function partnerWarehousePayload(context) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + return { + tenantId: 0, warehouseCode: `P4B-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 采购合作仓`, + warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA', + contactPhone: '+1-213-555-0177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null, + latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} procurement acceptance` }], + } +} + +async function createPartnerWarehouse(context) { + const payload = partnerWarehousePayload(context) + const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '采购合作仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) + return warehouseId ? { id: warehouseId, finalStatus: 'ACTIVE' } : null +} + +async function createMerchantProcurement(context, activeCustomer) { + const username = context.context?.merchantQualificationFixture?.username + const password = context.context?.merchantQualificationFixture?.password + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) + const overseasToken = auth?.overseasAccessToken + context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), + `customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS) + if (!overseasToken) return null + const payload = { + customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`, + purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`, + productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10, + budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: 'Los Angeles', + contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker, + idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255), + } + const response = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/overseas-procurements', payload, {}, overseasToken) + const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) + const value = summary(detail) + context.assert(MODULE, '客户采购订单创建为待审核且归属正确', response, + nonEmpty(value?.orderNo) && id(value?.customerId) === id(activeCustomer?.id) && value?.status === 'PENDING_REVIEW', + `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}; ${trace(response)}`, OPTIONS) + const purchase = value?.orderNo ? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId), version: Number(value.version), retained: true } : null + if (purchase) ownedRegistry(context).purchase = purchase + return purchase +} + +function nonEmpty(value) { return typeof value === 'string' && value.trim().length > 0 } + function markerOwnedPurchase(context) { const candidates = [context.createdResources?.phase4OverseasBusiness?.purchase, context.createdResources?.overseasProcurement] return candidates.find((item) => item?.marker === context.marker && id(item?.orderNo) && longId(item?.customerId)) || null @@ -65,7 +128,7 @@ async function procurementFlow(context, prerequisite) { } const customerId = id(prerequisite.procurementCustomer.customerId) const warehouseId = id(prerequisite.warehouse.warehouseId) - const list = await overseas(context, 'GET', '按真实客户与采购仓查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId, purchaseWarehouseId: warehouseId })) + const list = await overseas(context, 'GET', '按真实客户查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId })) assertPage(context, '海外采购订单', list) const owned = markerOwnedPurchase(context) const row = records(list.data).find((item) => owned && item?.orderNo === owned.orderNo && id(item?.customerId) === id(owned.customerId)) @@ -77,12 +140,22 @@ async function procurementFlow(context, prerequisite) { const detail = await overseas(context, 'GET', '查询本轮自有采购订单详情', detailPath) const current = summary(detail.data) const update = await overseas(context, 'PATCH', '编辑本轮自有采购订单', detailPath, - { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, remark: context.marker }) + { version: Number(current?.version), productSpecification: `${context.marker}-procurement`, + purchaseWarehouseId: warehouseId, purchaseWarehouseName: prerequisite.warehouse.warehouseName, + remark: context.marker }) const updated = summary(update.data) context.assert(MODULE, '本轮采购编辑回显 marker 且版本递增', update.response, updated?.productSpecification === `${context.marker}-procurement` && Number(updated?.version) === Number(current?.version) + 1, `orderNo=${id(updated?.orderNo) || 'missing'}, version=${updated?.version ?? 'missing'}; ${trace(update.response)}`, OPTIONS) ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(updated?.orderNo), customerId: id(updated?.customerId), version: Number(updated?.version), retained: true } + const reviewed = await overseas(context, 'POST', '驳回本轮采购订单并安全收口', + query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}/review`, { customerId }), + { decision: 'REJECT', remark: `${context.marker}-acceptance-cleanup` }) + const reviewedSummary = summary(reviewed.data) + context.assert(MODULE, '本轮采购订单终态为已驳回', reviewed.response, + reviewedSummary?.status === 'REJECTED' && Number(reviewedSummary?.version) === Number(updated?.version) + 1, + `orderNo=${id(reviewedSummary?.orderNo) || 'missing'}, status=${reviewedSummary?.status || 'missing'}, version=${reviewedSummary?.version ?? 'missing'}; ${trace(reviewed.response)}`, OPTIONS) + ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(reviewedSummary?.orderNo), customerId, version: Number(reviewedSummary?.version), finalStatus: reviewedSummary?.status, retained: reviewedSummary?.status !== 'REJECTED' } } function truckPayload(context) { @@ -215,8 +288,16 @@ async function looseboardFlow(context, customerId) { export async function run(context) { const prerequisite = await prerequisites(context) if (!prerequisite) return - await procurementFlow(context, prerequisite) - const customerId = id(prerequisite.activeCustomer.id) - await truckFlow(context, customerId) - await looseboardFlow(context, customerId) + try { + await procurementFlow(context, prerequisite) + const customerId = id(prerequisite.activeCustomer.id) + await truckFlow(context, customerId) + await looseboardFlow(context, customerId) + } finally { + if (prerequisite.partnerWarehouse?.id) { + const retired = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${encodeURIComponent(prerequisite.partnerWarehouse.id)}/status?tenantId=0&status=INACTIVE`) + context.addStep(MODULE, '本轮采购合作仓已停用收口', retired, + retired?.http === 200 && codeOf(retired) === '200', trace(retired), OPTIONS) + } + } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index df3577739..149546c51 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -18,6 +18,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, async requestOverseas(method, requestPath, body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) } if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) @@ -31,7 +33,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001' } }) + if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -54,8 +56,7 @@ test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) }) -test('仅托盘生命周期允许写请求,其他海外业务保持只读', () => { - assert.doesNotMatch(source, /requestOverseas\(['"]PATCH['"]/) +test('既有仓库场景只写本轮托盘生命周期', () => { const context = contextFixture() return run(context).then(() => { assert.ok(context.calls.length >= 12) @@ -82,7 +83,7 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => { assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' }) }) -test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID补洞', async () => { +test('缺少客户时记录缺口,缺少仓库时通过正式接口创建并停用fixture', async () => { const noCustomer = contextFixture({ tenants: [] }) await run(noCustomer) assert.equal(noCustomer.gaps.length, 1) @@ -91,8 +92,10 @@ test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID const noWarehouse = contextFixture({ warehouseRecords: [] }) await run(noWarehouse) - assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), true) - assert.equal(noWarehouse.calls.some((call) => call.path.includes('/pallets')), false) + assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), false) + assert.ok(noWarehouse.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin')) + assert.ok(noWarehouse.calls.some((call) => call.method === 'PATCH' && call.path.includes('/warehouse-admin/20001/status?'))) + assert.ok(noWarehouse.calls.some((call) => call.path.includes('/pallets'))) }) test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 292fb489b..789b88307 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -141,7 +141,12 @@ export async function run(context) { coverageGap(context, '换标创建与状态生命周期', '当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope') - const warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + let ownedWarehouse = false + if (!warehouse) { + warehouse = await createWarehouseFixture(context, customerId) + ownedWarehouse = Boolean(warehouse) + } if (!warehouse) return const warehouseId = id(warehouse.id) context.createdResources.phase4OverseasWarehouse.customerId = customerId @@ -160,8 +165,45 @@ export async function run(context) { `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - await verifyPalletLifecycle(context, warehouseId) - context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + try { + await verifyPalletLifecycle(context, warehouseId) + context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId } + } finally { + if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) + } +} + +async function createWarehouseFixture(context, customerId) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + const payload = { + customerId, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, + status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue', + addressLine2: null, latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} acceptance` }], + } + const created = await overseasMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) + if (!warehouseId) return null + const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) + context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId + && id(detail.data?.warehouse?.customerId || detail.data?.customerId) === customerId + && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', + `warehouseId=${warehouseId}, customerId=${id(detail.data?.warehouse?.customerId || detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' } +} + +async function retireWarehouseFixture(context, customerId, warehouseId) { + const retired = await context.requestOverseas('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { customerId, status: 'INACTIVE' })) + context.addStep(MODULE, '本轮客户海外仓已停用收口', retired, + retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS) } async function verifyPalletLifecycle(context, warehouseId) { @@ -237,6 +279,5 @@ async function findWarehouse(context, tenants, preferredCustomerId) { return warehouse } } - coverageGap(context, '公共托盘真实仓库前置', `ACTIVE 客户列表未找到可用海外仓;${trace(lastResponse)}`, 'missing-readonly-warehouse') return null }