fix(order): preserve submitted review status

This commit is contained in:
2026-09-02 12:19:46 +08:00
parent 9e70f4339a
commit a4d8fa9f8b
6 changed files with 204 additions and 24 deletions
@@ -46,7 +46,8 @@ class FinanceG1G2PermissionSqlContractTest {
@Test @Test
void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception { void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8)
.replace("\r\n", "\n");
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
@@ -297,8 +297,11 @@ public class UnifiedOrderQueryService {
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt()); order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
} }
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
boolean pendingReview = order.getStatus() == OrderStatus.SUBMITTED;
String mainStatus = pendingReview ? "PENDING_REVIEW" : summary.mainStatus();
String nextAction = pendingReview ? "APPROVE_ORDER" : summary.nextAction();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(), mainStatus, nextAction, relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
@@ -81,6 +81,26 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).summaries(any(), any()); verify(firstleg, never()).summaries(any(), any());
} }
@Test
void submittedOrderRemainsPendingReviewWhenPreparingInboundAlreadyExists() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.SUBMITTED);
var summary = summary(null, null);
when(summary.inbound().processStatus()).thenReturn("PREPARING");
when(summary.inbound().orderStatusSnapshot()).thenReturn("SUBMITTED");
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(summary)));
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.SUBMITTED);
});
}
@Test @Test
void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() {
UnifiedOrderQuery query = query(null); UnifiedOrderQuery query = query(null);
+4 -1
View File
@@ -521,9 +521,12 @@ export class AcceptanceRun {
const headers = { Accept: 'application/json', ...extraHeaders } const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
const requestBody = path === '/api/auth/login' && body?.username && !body?.loginId
? { ...body, loginId: body.username, username: undefined }
: body
try { try {
const response = await fetch(`${this.options.gateway}${path}`, { const response = await fetch(`${this.options.gateway}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body), method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}) })
const content = await response.text() const content = await response.text()
+61 -3
View File
@@ -25,6 +25,23 @@ function rotate(values, offset) {
return [...values.slice(shift), ...values.slice(0, shift)] return [...values.slice(shift), ...values.slice(0, shift)]
} }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
function declarationCargo(source, index) {
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0
const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value)
return {
itemNo: number(source?.itemNo) || index + 1,
cargoName: String(source?.cargoName || ''), cargoNameEn: source?.cargoNameEn || null,
skuNo: source?.skuNo || null, model: source?.model || null,
packageCount: number(source?.packageCount), totalQuantity: number(source?.totalQuantity),
lengthCm: number(source?.lengthCm), widthCm: number(source?.widthCm), heightCm: number(source?.heightCm),
grossWeightKg: number(source?.grossWeightKg), volumeCbm: number(source?.volumeCbm),
netWeightKg: optionalNumber(source?.netWeightKg), hsCode: source?.hsCode || null,
customsHsCode: source?.customsHsCode || null, unitsPerPackage: optionalNumber(source?.unitsPerPackage),
unitDeclaredValue: optionalNumber(source?.unitDeclaredValue), declaredValue: optionalNumber(source?.declaredValue),
currency: source?.currency || null, material: source?.material || null, usage: source?.usage || null,
brand: source?.brand || null, cargoRemark: source?.cargoRemark || source?.remark || null,
}
}
export function orderPersistenceFacts(detail) { export function orderPersistenceFacts(detail) {
return JSON.stringify({ return JSON.stringify({
order: detail?.order ?? null, order: detail?.order ?? null,
@@ -353,7 +370,7 @@ function buildPayload(context, profile, master, productImageFileId) {
const selectedBoxes = BOXES.slice(0, skuCount) const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {} const orderProfile = context.context?.orderProfile || {}
const isPrivate = master.destinationType === 'PRIVATE' const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'PICKUP' const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
const receiver = isPrivate ? { const receiver = isPrivate ? {
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US', phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US',
@@ -405,7 +422,7 @@ function buildPayload(context, profile, master, productImageFileId) {
shipmentId: master.destinationType === 'FBA' ? shipmentId : null, shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null,
upcNo: `${Date.now()}${index}`.slice(-12), upcNo: `${Date.now()}${index}`.slice(-12),
productImageFileId, dangerousGoods: false, productImageFileId, dangerousGoods: false, woodenProduct: false,
cargoRemark: `${context.marker} boxType=${box.boxType}`, cargoRemark: `${context.marker} boxType=${box.boxType}`,
} }
}), }),
@@ -665,9 +682,50 @@ export async function run(context) {
pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId), pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId),
`inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`) `inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`)
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
const declarationVersion = Number(editableDetail?.order?.version)
const declarationRemark = `${context.marker} Admin 修改客户申报资料`
const declarationPayload = {
expectedVersion: declarationVersion,
customerRemark: declarationRemark,
expectedArrivalAt: editableDetail?.order?.expectedArrivalAt || editableDetail?.summary?.expectedArrivalAt || null,
channelCode: editableDetail?.order?.channelCode || editableDetail?.summary?.channelCode,
channelName: editableDetail?.order?.channelName || editableDetail?.summary?.channelName,
cargoes: (editableDetail?.cargoes || []).map(declarationCargo),
}
const declarationUpdateResponse = await context.request('PUT',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
declarationPayload)
const declarationUpdate = context.apiSuccess(MODULE, 'Admin 修改审核后客户申报资料', declarationUpdateResponse)
context.assert(MODULE, '申报修改保持订单号入仓码和渠道且版本递增', declarationUpdateResponse,
Number.isFinite(declarationVersion) && Number(declarationUpdate?.version) === declarationVersion + 1
&& id(declarationUpdate?.inboundCode) === id(inbound.summary.inboundCode)
&& declarationUpdate?.channelChanged === false,
`version=${declarationVersion}->${declarationUpdate?.version ?? 'missing'}, inboundCode=${declarationUpdate?.inboundCode ?? 'missing'}`,
CONTRACT_OPTIONS)
const staleDeclarationResponse = await context.request('PUT',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
{ ...declarationPayload, customerRemark: `${declarationRemark} stale` })
context.assert(MODULE, '申报资料旧版本并发修改返回A0409', staleDeclarationResponse,
staleDeclarationResponse.http === 200 && String(staleDeclarationResponse.body?.code) === 'A0409',
`code=${staleDeclarationResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
let declarationConverged = false
let convergedInboundResponse = inboundResponse
for (let attempt = 0; attempt < 30; attempt += 1) {
convergedInboundResponse = await context.request('GET', inboundPath)
const currentInbound = success(convergedInboundResponse) ? convergedInboundResponse.body?.data : null
if (currentInbound?.publicRemark === declarationRemark) { declarationConverged = true; break }
if (attempt < 29) await sleep(1_000)
}
context.assert(MODULE, '申报修改异步同步到Firstleg入库事实', convergedInboundResponse,
declarationConverged, `expectedRemark=${declarationRemark}`, CONTRACT_OPTIONS)
context.createdResources.orders.inboundId = inboundId context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode context.createdResources.orders.inboundCode = inbound.summary.inboundCode
context.createdResources.orders.customerRemark = payload.customerRemark context.createdResources.orders.customerRemark = declarationRemark
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收` context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
context.createdResources.orders.processStatus = inbound.summary.processStatus context.createdResources.orders.processStatus = inbound.summary.processStatus
+113 -18
View File
@@ -42,6 +42,7 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
const now = new Date() const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!` const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!`
const phoneSuffix = `${Date.now()}`.slice(-8) const phoneSuffix = `${Date.now()}`.slice(-8)
const legalPhone = `139${phoneSuffix}` const legalPhone = `139${phoneSuffix}`
@@ -91,6 +92,7 @@ try {
await qualificationAndReviewModule() await qualificationAndReviewModule()
await merchantAfterApprovalModule() await merchantAfterApprovalModule()
await profileManagementModule() await profileManagementModule()
await loginIdentifierModule()
await rbacAndMemberModule() await rbacAndMemberModule()
await orderLifecycleModule() await orderLifecycleModule()
await isolationModule() await isolationModule()
@@ -117,7 +119,7 @@ async function registrationModule() {
displayName: `测试管理员-${marker}`.slice(0, 64), displayName: `测试管理员-${marker}`.slice(0, 64),
companyName, companyName,
legalPerson: '测试法人', legalPerson: '测试法人',
legalPhone, phone: legalPhone,
contactEmail: `${username}@example.com`, contactEmail: `${username}@example.com`,
countryCode: 'CN', countryCode: 'CN',
address: '广东省深圳市南山区测试路 1 号', address: '广东省深圳市南山区测试路 1 号',
@@ -213,7 +215,6 @@ async function qualificationAndReviewModule() {
const png = testPng() const png = testPng()
const form = new FormData() const form = new FormData()
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`) form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
form.append('legalPersonId', new Blob([png], { type: 'image/png' }), `legal-person-id-${marker}.png`)
await apiStep('qualification-review', 'upload required qualification package', { await apiStep('qualification-review', 'upload required qualification package', {
method: 'POST', method: 'POST',
@@ -223,7 +224,7 @@ async function qualificationAndReviewModule() {
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id') assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
assertPresent(json.data?.legalPersonIdQualificationId, 'legal person qualification id') assertTrue(json.data?.legalPersonIdQualificationId == null, 'legal person qualification id must stay optional')
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload') assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload')
}, ({ json }) => ({ }, ({ json }) => ({
businessLicenseQualificationId: json.data.businessLicenseQualificationId, businessLicenseQualificationId: json.data.businessLicenseQualificationId,
@@ -274,7 +275,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'optional legal person ID must remain absent')
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING') assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -293,6 +296,19 @@ async function qualificationAndReviewModule() {
}, () => ({ username: platformUsername })) }, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken platformToken = platformLogin.json.data.accessToken
const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization`
for (let attempt = 0; attempt < 30; attempt += 1) {
const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken })
if (String(statusResponse.json?.data?.status) === 'READY') break
if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
await apiStep('qualification-review', 'customer default dimensions are ready before approval', {
method: 'GET', path: dimensionPath, token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'READY', 'dimension initialization status')
}, ({ json }) => ({ status: json.data.status, attemptCount: json.data.attemptCount }))
const reviews = await apiStep('qualification-review', 'platform finds pending review', { const reviews = await apiStep('qualification-review', 'platform finds pending review', {
method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken, method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken,
}, ({ json }) => { }, ({ json }) => {
@@ -310,7 +326,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken, method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'platform must not require a legal person ID record')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
const approval = await apiStep('qualification-review', 'platform approves enterprise', { const approval = await apiStep('qualification-review', 'platform approves enterprise', {
@@ -343,6 +361,51 @@ async function qualificationAndReviewModule() {
}) })
} }
async function loginIdentifierModule() {
const staleToken = merchantToken
await apiStep('login-identifiers', 'change username with current-password verification', {
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
body: { username: temporaryUsername, phone: legalPhone, currentPassword: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.username, temporaryUsername, 'updated username')
assertEqual(json.data?.phone, legalPhone, 'updated phone')
}, ({ json }) => ({ username: json.data.username, phone: json.data.phone }))
await apiStep('login-identifiers', 'identifier change revokes the previous access token', {
method: 'GET', path: '/api/merchant/v1/iam/profile', token: staleToken,
}, ({ status, json }) => {
assertEqual(status, 401, 'HTTP status')
assertTrue(String(json?.code || '').length > 0, 'authentication error code must be present')
}, ({ status, json }) => ({ status, rejectionCode: json?.code }))
const renamedLogin = await apiStep('login-identifiers', 'login with the changed username', {
method: 'POST', path: '/api/auth/login', body: { username: temporaryUsername, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username: temporaryUsername }))
merchantToken = renamedLogin.json.data.accessToken
merchantRefreshToken = renamedLogin.json.data.refreshToken
await apiStep('login-identifiers', 'restore the original username for the remaining acceptance flow', {
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
body: { username, phone: legalPhone, currentPassword: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.username, username, 'restored username')
}, ({ json }) => ({ username: json.data.username }))
const restoredLogin = await apiStep('login-identifiers', 'login after restoring the original username', {
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username }))
merchantToken = restoredLogin.json.data.accessToken
merchantRefreshToken = restoredLogin.json.data.refreshToken
}
async function merchantAfterApprovalModule() { async function merchantAfterApprovalModule() {
const login = await apiStep('merchant-session', 'client re-login after approval', { const login = await apiStep('merchant-session', 'client re-login after approval', {
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
@@ -365,7 +428,9 @@ async function merchantAfterApprovalModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'APPROVED') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'APPROVED')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'approved enterprise must not gain a synthetic legal person ID record')
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED') assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -1004,9 +1069,9 @@ async function orderLifecycleModule() {
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') })) }, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval') const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval')
const invalidOrderCases = [ const incompleteSubmitCases = [
{ {
name: 'FBA order missing Amazon identifiers is rejected', name: 'FBA order missing Amazon identifiers',
mutate(value) { mutate(value) {
value.amazonWarehouseCode = null value.amazonWarehouseCode = null
value.shipmentId = null value.shipmentId = null
@@ -1014,7 +1079,7 @@ async function orderLifecycleModule() {
}, },
}, },
{ {
name: 'FBA cargo missing SKU UPC shipment and reference is rejected', name: 'FBA cargo missing SKU UPC shipment and reference',
mutate(value) { mutate(value) {
value.cargoes[0].skuNo = null value.cargoes[0].skuNo = null
value.cargoes[0].upcNo = null value.cargoes[0].upcNo = null
@@ -1022,9 +1087,35 @@ async function orderLifecycleModule() {
value.cargoes[0].referenceId = null value.cargoes[0].referenceId = null
}, },
}, },
]
for (const testCase of incompleteSubmitCases) {
const incompletePayload = structuredClone(payload)
testCase.mutate(incompletePayload)
const draft = await apiStep('order-validation', `${testCase.name} can be saved as a draft`, {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-draft') }, body: incompletePayload,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data, 'incomplete draft order id')
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
report.resources.orderIds.push(draft.json.data)
await softApiStep('order-validation', `${testCase.name} is rejected on submit`, {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ orderId: draft.json.data, rejectionCode: json?.code, message: json?.message }))
}
const invalidOrderCases = [
{ {
name: 'PICKUP order without sender is rejected', name: 'PICKUP order without a selected pickup address is rejected',
mutate(value) { value.sender = null }, mutate(value) {
value.pickupMethod = 'PICKUP'
value.requestedPickupAt = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19)
value.sender = null
value.senderAddressId = null
},
}, },
{ {
name: 'PRIVATE destination without receiver is rejected', name: 'PRIVATE destination without receiver is rejected',
@@ -1191,11 +1282,11 @@ async function orderLifecycleModule() {
requireSuccess(json) requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order') assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId })) }, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'sender phone filter contains submitted order', { await apiStep('order-filters', 'customer contact filter contains submitted order by customer name', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(payload.customerName)}`, token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'sender-phone-filtered order') assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'customer-contact-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId })) }, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'unmatched keyword returns an empty order page', { await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
@@ -1815,7 +1906,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
destinationWarehouseName: masterData.destination.warehouseName, destinationWarehouseName: masterData.destination.warehouseName,
expectedShipDate: addDays(new Date(), 30), expectedShipDate: addDays(new Date(), 30),
customerRemark: `${remark} ${marker}`, customerRemark: `${remark} ${marker}`,
pickupMethod: 'PICKUP', pickupMethod: 'SELF_EXPRESS',
receivingWarehouseId: masterData.receiving.id, receivingWarehouseId: masterData.receiving.id,
receivingWarehouseName: masterData.receiving.warehouseName, receivingWarehouseName: masterData.receiving.warehouseName,
channelCode: masterData.channel.channelCode, channelCode: masterData.channel.channelCode,
@@ -1867,6 +1958,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12), upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
productImageFileId, productImageFileId,
dangerousGoods: false, dangerousGoods: false,
woodenProduct: false,
cargoRemark: `cargo-${remark} ${marker}`, cargoRemark: `cargo-${remark} ${marker}`,
}], }],
} }
@@ -1911,10 +2003,13 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}` if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body const normalizedBody = path === '/api/auth/login' && body?.username && !body?.loginId
if (body != null && !(body instanceof FormData)) { ? { ...body, loginId: body.username, username: undefined }
: body
let requestBody = normalizedBody
if (normalizedBody != null && !(normalizedBody instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json' requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body) requestBody = JSON.stringify(normalizedBody)
} }
const response = await fetch(`${gateway}${path}`, { const response = await fetch(`${gateway}${path}`, {
method, method,