diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 5ceaa3718..65ed89ea0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -167,7 +167,11 @@ public class OceanBookingService { String action = "ALLOCATE_CONTAINER"; Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); if (replay != null) return replay; - requiredVersion(id, request.expectedVersion()); + OceanBookingEntity booking = requiredVersion(id, request.expectedVersion()); + if (!Set.of(OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), + OceanBookingStatus.CHANGE_PENDING.name()).contains(booking.getStatus())) { + throw conflict("当前订舱状态不可分配柜次"); + } OceanBookingContainerRequirementEntity requirement = repository.requirement(id, request.requirementId()); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "箱型需求不存在"); ContainerBookingEntity container = repository.containerBooking(request.containerBookingId()); @@ -179,7 +183,7 @@ public class OceanBookingService { OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(id); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(request.containerBookingId()); - allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name); + allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name); allocation.setAllocatedAt(LocalDateTime.now()); try { repository.insert(allocation); } catch (DuplicateKeyException ex) { throw conflict("柜次已被其他订舱占用"); } return complete(id, operatorId, action, key, detail(id)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 1f1addc3b..f6ac4e369 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -4,6 +4,10 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; @@ -107,6 +111,44 @@ class OceanBookingServiceTest { verify(repository, never()).find(anyLong()); } + @Test + void allocatesContainerWithExplicitTimestampAndVersionGuard() { + when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6)); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ"); + requirement.setQuantity(1); + ContainerBookingEntity container = new ContainerBookingEntity(); + container.setId(12L); container.setContainerType("40HQ"); + when(repository.requirement(1L, 11L)).thenReturn(requirement); + when(repository.containerBooking(12L)).thenReturn(container); + when(repository.touchForAllocation(1L, 5, 9L, "admin")).thenReturn(1); + + Detail result = service.allocate(1L, 9L, "admin", "allocate-1", + new AllocationRequest(11L, 12L, 5)); + + ArgumentCaptor allocation = + ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class); + verify(repository).insert(allocation.capture()); + assertThat(allocation.getValue().getOceanBookingId()).isEqualTo(1L); + assertThat(allocation.getValue().getRequirementId()).isEqualTo(11L); + assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(12L); + assertThat(allocation.getValue().getAllocatedAt()).isNotNull(); + assertThat(result.oceanBooking().getVersion()).isEqualTo(6); + } + + @Test + void rejectsAllocationAfterReleaseBeforeMutation() { + when(repository.find(1L)).thenReturn(booking("RELEASED", 5)); + + assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released", + new AllocationRequest(11L, 12L, 5))) + .isInstanceOf(BizException.class) + .hasMessageContaining("当前订舱状态不可分配柜次"); + + verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class)); + } + private String hashForSubmit() throws Exception { var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); method.setAccessible(true); diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 142707b88..071e46480 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -20,6 +20,7 @@ async function createBooking(context, suffix, key) { return request(context, 'PO function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } export async function run(context) { + const containerFlow = context.createdResources?.phase3ContainerLoading const suffix = context.marker.replace(/\D/g, '').slice(-10) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) if (missing(list) || list.http === 0) { @@ -56,11 +57,32 @@ export async function run(context) { await action('确认订舱', 'confirm', version, 'marker confirm') const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) - await action('确认改船', 'change-confirm', version, 'confirm change') + const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') + if (containerFlow?.finalStatus === 'COMPLETED') { + context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, + id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), + `bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS) + const requirement = changeConfirmed?.containerRequirements?.find?.((item) => item?.containerType === '40HQ') + context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed, + Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) + if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return + const allocation = await request(context, 'POST', `${path}/allocations`, { + requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: version, + }, { 'Idempotency-Key': `${context.marker}-ocean-container-allocation`.slice(0, 128) }) + const allocated = context.apiSuccess(MODULE, '放舱前分配本轮柜次', allocation, '', OPTIONS) + version = Number(allocated?.oceanBooking?.version) + context.assert(MODULE, '订舱柜次分配持久化且版本递增', allocation, + allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + && Number.isInteger(version), + `containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS) + } await action('放舱', 'release', version, 'release') const final = await request(context, 'GET', path); const fd = data(final) context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS) - context.assert(MODULE, '订舱状态历史、审计操作者和终态完整', final, fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName), `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}`, OPTIONS) + context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final, + fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) + && (!containerFlow || fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))), + `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) } diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index c982eed6e..630c11175 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -5,26 +5,6 @@ const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } -async function request(context, method, path, body, headers = {}) { - try { - const response = await fetch(`${context.options.gateway}${path}`, { - method, - headers: { - Accept: 'application/json', Authorization: `Bearer ${context.token}`, - ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, - }, - body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), - }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method, path, http: response.status, body: payload, - raw: payload === null ? raw.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), - traceId: payload?.traceId || response.headers.get('x-trace-id') } - } catch (error) { - return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } - } -} export async function run(context) { const ocean = context.createdResources?.oceanBooking @@ -46,26 +26,15 @@ export async function run(context) { && id(containerFlow?.tenantId) === customerId && Boolean(containerFlow?.containerBookingId) && Boolean(containerFlow?.loadingPlanId) - let oceanVersion = Number(oceanDetail?.oceanBooking?.version) if (containerFlow && !linkContainer) { context.addStep(MODULE, '本轮柜次与订舱客户归属可串联', oceanResponse, false, `oceanCustomerId=${customerId}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}, containerStatus=${containerFlow?.finalStatus || 'missing'}`, OPTIONS) return } if (linkContainer) { - const requirement = oceanDetail?.containerRequirements?.find?.((item) => item?.containerType === '40HQ') - context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', oceanResponse, - Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) - if (!requirement?.id || !Number.isInteger(oceanVersion)) return - const allocationResponse = await request(context, 'POST', `${OCEAN}/${ocean.id}/allocations`, { - requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: oceanVersion, - }, { 'Idempotency-Key': `${context.marker}-flow-container-allocation`.slice(0, 128) }) - const allocated = context.apiSuccess(MODULE, '将本轮柜次分配至已放舱订舱', allocationResponse, '', OPTIONS) - oceanVersion = Number(allocated?.oceanBooking?.version) - context.assert(MODULE, '订舱分配保留柜次关联并递增版本', allocationResponse, - allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - && Number.isInteger(oceanVersion), - `containerBookingId=${id(containerFlow.containerBookingId)}, version=${oceanVersion}`, OPTIONS) + context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, + oceanDetail?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + `containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) } const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null,