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
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 人工财务权限"),
sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
@@ -297,8 +297,11 @@ public class UnifiedOrderQueryService {
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
}
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(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(),
mainStatus, nextAction, relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
@@ -81,6 +81,26 @@ class UnifiedOrderQueryServiceTest {
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
void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() {
UnifiedOrderQuery query = query(null);
+4 -1
View File
@@ -521,9 +521,12 @@ export class AcceptanceRun {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
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 {
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),
})
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)]
}
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) {
return JSON.stringify({
order: detail?.order ?? null,
@@ -353,7 +370,7 @@ function buildPayload(context, profile, master, productImageFileId) {
const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {}
const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'PICKUP'
const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
const receiver = isPrivate ? {
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
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,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
upcNo: `${Date.now()}${index}`.slice(-12),
productImageFileId, dangerousGoods: false,
productImageFileId, dangerousGoods: false, woodenProduct: false,
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),
`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.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.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
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 marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
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 phoneSuffix = `${Date.now()}`.slice(-8)
const legalPhone = `139${phoneSuffix}`
@@ -91,6 +92,7 @@ try {
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
await profileManagementModule()
await loginIdentifierModule()
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
@@ -117,7 +119,7 @@ async function registrationModule() {
displayName: `测试管理员-${marker}`.slice(0, 64),
companyName,
legalPerson: '测试法人',
legalPhone,
phone: legalPhone,
contactEmail: `${username}@example.com`,
countryCode: 'CN',
address: '广东省深圳市南山区测试路 1 号',
@@ -213,7 +215,6 @@ async function qualificationAndReviewModule() {
const png = testPng()
const form = new FormData()
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', {
method: 'POST',
@@ -223,7 +224,7 @@ async function qualificationAndReviewModule() {
}, ({ json }) => {
requireSuccess(json)
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')
}, ({ json }) => ({
businessLicenseQualificationId: json.data.businessLicenseQualificationId,
@@ -274,7 +275,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ 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')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -293,6 +296,19 @@ async function qualificationAndReviewModule() {
}, () => ({ username: platformUsername }))
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', {
method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken,
}, ({ json }) => {
@@ -310,7 +326,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
}, ({ 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) }))
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() {
const login = await apiStep('merchant-session', 'client re-login after approval', {
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,
}, ({ 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')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -1004,9 +1069,9 @@ async function orderLifecycleModule() {
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
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) {
value.amazonWarehouseCode = 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) {
value.cargoes[0].skuNo = null
value.cargoes[0].upcNo = null
@@ -1022,9 +1087,35 @@ async function orderLifecycleModule() {
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',
mutate(value) { value.sender = null },
name: 'PICKUP order without a selected pickup address is rejected',
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',
@@ -1191,11 +1282,11 @@ async function orderLifecycleModule() {
requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'sender phone filter contains submitted order', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken,
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(payload.customerName)}`, token: merchantToken,
}, ({ 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 }))
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,
@@ -1815,7 +1906,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
destinationWarehouseName: masterData.destination.warehouseName,
expectedShipDate: addDays(new Date(), 30),
customerRemark: `${remark} ${marker}`,
pickupMethod: 'PICKUP',
pickupMethod: 'SELF_EXPRESS',
receivingWarehouseId: masterData.receiving.id,
receivingWarehouseName: masterData.receiving.warehouseName,
channelCode: masterData.channel.channelCode,
@@ -1867,6 +1958,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
productImageFileId,
dangerousGoods: false,
woodenProduct: false,
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' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
const normalizedBody = path === '/api/auth/login' && body?.username && !body?.loginId
? { ...body, loginId: body.username, username: undefined }
: body
let requestBody = normalizedBody
if (normalizedBody != null && !(normalizedBody instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
requestBody = JSON.stringify(normalizedBody)
}
const response = await fetch(`${gateway}${path}`, {
method,