From e9564a21a273d803dd76df552b4fa4f940f1154b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 13 Aug 2026 17:14:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ormInboundMeasurementReviewController.java | 10 ++- .../main/resources/openapi/firstleg-v1.yaml | 17 ++++- .../InboundMeasurementReviewContractTest.java | 35 ++++++++++ .../truck-appointments-contract.test.mjs | 70 +++++++++++++++++++ .../truck-appointments.mjs | 66 +++++++++++++++-- 5 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 scripts/admin-test-env-modules/truck-appointments-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index 8ddd133a3..5faba346d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.service.MeasurementReviewService; +import com.baiyue.firstleg.service.AutomaticInboundCompletionService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,6 +36,7 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/inbounds") public class PlatformInboundMeasurementReviewController { @Resource private MeasurementReviewService service; + @Resource private AutomaticInboundCompletionService automaticCompletion; @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量审核通过") @@ -50,10 +52,12 @@ public class PlatformInboundMeasurementReviewController { @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody MeasurementReviewRequest request) { validateScope(user, tenantId); + // 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING, + // 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误, + // 使用同一幂等键重试会从持久化阶段继续推进。 service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料 - // 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。 - return ApiResponse.success(null); + return ApiResponse.success(automaticCompletion.completeApproved( + tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1)); } @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 093d4ba94..d7ef03599 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -537,12 +537,25 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限;客服审核通过后自动计费、发布费用给客户并完成入库,客户无需确认。 + description: 需要 firstleg:inbound:measurement-review 权限。审核事务提交后服务端自动执行计费、费用发布和入库编排并返回费用快照;若后续编排失败,审核结果保持已提交,使用同一 Idempotency-Key 重试将从持久化阶段继续,接口不会伪造 WAIT_SPLIT。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [measurementId, expectedVersion] + properties: + measurementId: {type: string, pattern: '^[1-9][0-9]*$'} + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, maxLength: 1000} responses: - '200': {description: 成功或业务失败} + '200': {description: 成功或业务失败;成功 data 为 FeeSnapshotResponse,nextStatus=WAIT_SPLIT} + '409': {description: 审核已提交但自动编排尚未完成、版本冲突或费用/仓库门禁失败;不得据此推断 WAIT_SPLIT} /api/platform/v1/inbounds/{inboundId}/measurements/reject: post: summary: 驳回测量 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index c058ebe6b..acff1ed14 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -5,15 +5,21 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.MeasurementReviewService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @DisplayName("入库测量审核控制器契约") class InboundMeasurementReviewContractTest { @@ -40,11 +46,40 @@ class InboundMeasurementReviewContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") .contains("firstleg:inbound:exception-release") .contains("Idempotency-Key") + .contains("审核事务提交后服务端自动执行计费") + .contains("接口不会伪造 WAIT_SPLIT") + .contains("成功 data 为 FeeSnapshotResponse,nextStatus=WAIT_SPLIT") .contains("name: inboundId") .contains("schema: {type: string, pattern:") .doesNotContain("待客户确认"); } + @Test + @DisplayName("审核事务完成后才启动自动编排并返回真实费用快照") + void shouldCompleteApprovedReviewAfterReviewTransactionReturns() { + MeasurementReviewService review = mock(MeasurementReviewService.class); + AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class); + PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController(); + ReflectionTestUtils.setField(controller, "service", review); + ReflectionTestUtils.setField(controller, "automaticCompletion", completion); + AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null, + "客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review")); + MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过"); + var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class); + when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee); + + var first = controller.approve(user, 10001L, 88L, "approve-1", request); + var replay = controller.approve(user, 10001L, 88L, "approve-1", request); + + assertThat(first.data()).isSameAs(fee); + assertThat(replay.data()).isSameAs(fee); + var ordered = inOrder(review, completion); + ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); + ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); + ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); + ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); + } + @Test void shouldAuditAutomaticInboundCompletion() throws Exception { Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved", diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs new file mode 100644 index 000000000..28f9aa573 --- /dev/null +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs' + +test('truck appointment accepts only the current marker ocean booking identity', () => { + const current = inspectOceanBookingIdentity('RUN-2', { + id: '91', marker: 'RUN-2', customerId: '21', warehouseId: '41', customerName: '本轮客户', + }) + assert.deepEqual(current.identity, { + oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', + }) + const stale = inspectOceanBookingIdentity('RUN-2', { + id: '90', marker: 'RUN-1', customerId: '20', warehouseId: '40', customerName: '历史客户', + }) + assert.equal(stale.identity, null) + assert.match(stale.reason, /marker 不属于本轮/) +}) + +test('truck appointment fills missing identity only from the current booking detail', () => { + const partial = { id: '91', marker: 'RUN-2', customerId: '21' } + const beforeDetail = inspectOceanBookingIdentity('RUN-2', partial) + assert.equal(beforeDetail.identity, null) + assert.deepEqual(beforeDetail.missingFields, ['warehouseId', 'customerName']) + const resolved = inspectOceanBookingIdentity('RUN-2', partial, { oceanBooking: { + id: 91, customerId: 21, warehouseId: 41, customerName: '本轮客户', + } }) + assert.deepEqual(resolved.identity, { + oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', + }) +}) + +test('truck appointment rejects mismatched booking detail instead of mixing identities', () => { + const wrongResource = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2' }, { + oceanBooking: { id: '92', customerId: '21', warehouseId: '41', customerName: '其他订舱' }, + }) + assert.equal(wrongResource.identity, null) + assert.match(wrongResource.reason, /资源归属不一致/) + + const conflictingCustomer = inspectOceanBookingIdentity('RUN-2', { + id: '91', marker: 'RUN-2', customerId: '21', + }, { oceanBooking: { id: '91', customerId: '22', warehouseId: '41', customerName: '本轮客户' } }) + assert.equal(conflictingCustomer.identity, null) + assert.match(conflictingCustomer.reason, /customerId/) +}) + +test('create edit and stale replay payloads keep the resolved booking identity stable', () => { + const context = { marker: 'RUN-2' } + const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' } + const payloads = [ + truckAppointmentPayload(context, '2', identity), + truckAppointmentPayload(context, '2', identity, 3), + truckAppointmentPayload(context, '2', identity, 0), + ] + for (const payload of payloads) { + assert.deepEqual({ + oceanBookingId: payload.oceanBookingId, customerId: payload.customerId, + warehouseId: payload.warehouseId, customerName: payload.customerName, + }, identity) + } + assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0]) +}) + +test('truck appointment source has no historical list or hardcoded identity fallback', () => { + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/) + assert.match(source, /\/api\/platform\/v1\/ocean-bookings/) + assert.match(source, /未使用历史订舱或硬编码客户\/仓库/) +}) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 435a4a009..bf397c297 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,6 +1,7 @@ const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/truck-appointments' +const OCEAN_BOOKING_BASE = '/api/platform/v1/ocean-bookings' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } @@ -36,10 +37,39 @@ async function uploadAttachment(context, appointmentId) { } catch (error) { return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -function payload(context, suffix, version = 0) { +export function inspectOceanBookingIdentity(marker, resource, detail = null) { + if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' } + if (resource.marker !== marker) { + return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` } + } + const booking = detail?.oceanBooking || null + if (booking && id(booking.id) !== id(resource.id)) { + return { identity: null, reason: `订舱详情资源归属不一致:expected=${id(resource.id)}, actual=${id(booking.id) || 'missing'}` } + } + const fields = ['customerId', 'warehouseId', 'customerName'] + for (const field of fields) { + const published = id(resource[field]) + const queried = id(booking?.[field]) + if (published && queried && published !== queried) { + return { identity: null, reason: `订舱身份字段冲突:field=${field}, published=${published}, detail=${queried}` } + } + } + const identity = { + oceanBookingId: id(resource.id), + customerId: id(resource.customerId || booking?.customerId), + warehouseId: id(resource.warehouseId || booking?.warehouseId), + customerName: id(resource.customerName || booking?.customerName), + } + const missingFields = fields.filter((field) => !identity[field]) + return missingFields.length + ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields } + : { identity, reason: null, missingFields: [] } +} + +export function truckAppointmentPayload(context, suffix, identity, version = 0) { return { - outboundOrderId: null, oceanBookingId: context.createdResources?.oceanBooking?.id || null, containerBookingId: null, loadingPlanId: null, - warehouseId: '193000000000000001', customerId: '10001', customerName: `拖车验收客户-${suffix}`, + outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null, + warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName, carrierId: null, carrierName: `承运商-${suffix}`, fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', @@ -64,13 +94,35 @@ export async function run(context) { const oceanBookings = await import(new URL('./ocean-bookings.mjs', import.meta.url)) await oceanBookings.run(context) } - if (!context.createdResources?.oceanBooking?.id) { + const oceanBooking = context.createdResources?.oceanBooking + if (!oceanBooking?.id) { context.addStep(MODULE, '创建拖车预约所需隔离订舱 fixture', null, false, '本轮 ocean-bookings 未返回可关联的隔离订舱,未使用历史订舱', OPTIONS) return } + let identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking) + if (!identityResult.identity && identityResult.missingFields?.length) { + const bookingPath = `${OCEAN_BOOKING_BASE}/${encodeURIComponent(id(oceanBooking.id))}` + const bookingResponse = await request(context, 'GET', bookingPath) + if (bookingResponse.http !== 200 || codeOf(bookingResponse) !== '200') { + context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, false, + `path=${bookingPath}, HTTP=${bookingResponse.http}, code=${codeOf(bookingResponse)}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS) + return + } + identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking, data(bookingResponse)) + context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, Boolean(identityResult.identity), + identityResult.identity + ? `oceanBookingId=${identityResult.identity.oceanBookingId}, customerId=${identityResult.identity.customerId}, warehouseId=${identityResult.identity.warehouseId}` + : `${identityResult.reason}, path=${bookingPath}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS) + } + if (!identityResult.identity) { + context.addStep(MODULE, '创建拖车预约所需本轮订舱身份', null, false, + `${identityResult.reason};未使用历史订舱或硬编码客户/仓库`, OPTIONS) + return + } + const bookingIdentity = Object.freeze({ ...identityResult.identity }) - const createResponse = await request(context, 'POST', BASE, payload(context, suffix)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS) + const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS) const appointmentId = id(appointment?.id) context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS) if (!appointmentId) return @@ -87,8 +139,8 @@ export async function run(context) { if (attachmentId) { const removed = await request(context, 'DELETE', `${path}/attachments/${attachmentId}`); context.apiSuccess(MODULE, '删除本轮拖车预约附件引用', removed, `attachmentId=${attachmentId}`, OPTIONS) } let version = Number(appointment.version) - const edit = await request(context, 'PUT', path, { ...payload(context, suffix, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) - const stale = await request(context, 'PUT', path, { ...payload(context, suffix, 0), vehicleNo: `STALE-${suffix}` }) + const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) + const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, 0), vehicleNo: `STALE-${suffix}` }) context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS) const after = await request(context, 'GET', path); const afterData = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS) context.assert(MODULE, '拖车预约冲突未部分更新', after, afterData?.vehicleNo === edited?.vehicleNo, `vehicleNo=${afterData?.vehicleNo || 'missing'}`, OPTIONS); version = Number(afterData?.version)