From 3b38fdd33d8dae412b574b15924d82f96fce416a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 13:50:33 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/dto/LoadingListDtos.java | 1 + .../repository/LoadingListRepository.java | 2 +- .../resources/openapi/loading-lists-v1.yaml | 3 +- .../LoadingListControllerContractTest.java | 2 +- .../inbound-weight-ocr-contract.test.mjs | 8 +- .../inbound-weight-ocr.mjs | 27 ++++-- ...ght-pallet-page-response-contract.test.mjs | 86 +++++++++++++++++++ 7 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index d7b45a413..e3b9327f9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -200,6 +200,7 @@ public final class LoadingListDtos { public record History( Long historyId, Long itemId, + String operationId, String actionType, String fromStatus, String toStatus, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index e363c9a2d..c4c32a067 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -188,7 +188,7 @@ public class LoadingListRepository { private List histories(long planId) { return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(), - row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), + row.getOperationId(), row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(), row.getOccurredAt())).toList(); } diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 18b436f15..4e556e7d3 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -427,10 +427,11 @@ components: itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion} OperationHistory: type: object - required: [historyId, actionType, operatorId, occurredAt] + required: [historyId, operationId, actionType, operatorId, occurredAt] properties: historyId: {$ref: '#/components/schemas/LongId'} itemId: {$ref: '#/components/schemas/NullableLongId'} + operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重} actionType: {type: string, description: 操作类型} fromStatus: {type: string, nullable: true, description: 操作前状态} toStatus: {type: string, nullable: true, description: 操作后状态} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 28be884a7..a2ecf3ca4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -180,7 +180,7 @@ class LoadingListControllerContractTest { "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", "destinationWarehouse", "productQuantity", "frozenAt"); assertThat(properties(schemas, "OperationHistory")).containsKeys( - "historyId", "itemId", "actionType", "fromStatus", "toStatus", + "historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus", "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys( "containerNo", "voyageNo", "expectedVersion", "operationReason"); diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs index d90328480..40684161b 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { confirmedNetSummary, + normalizeWeightPalletOptionPage, normalizedGrossKg, palletOptionsPath, selectWeightPallet, @@ -50,10 +51,13 @@ test('HTTP scenario uses pallet option and net weight contracts without legacy f assert.match(optionsPath, /keyword=TP-09/) assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50) assert.deepEqual(weightPalletOptionRecords({ - records: [{ palletId: '1' }], page: 1, size: 50, total: 1000, pages: 20, + records: [{ palletId: '1' }], page: '1', size: '50', total: '1000', pages: '20', }), [{ palletId: '1' }]) + assert.deepEqual(normalizeWeightPalletOptionPage({ + records: [], page: '1', size: '20', total: '0', pages: '0', + }), { records: [], page: 1, size: 20, total: 0, pages: 0 }) assert.equal(weightPalletOptionRecords([{ palletId: '1' }]), null) - assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000 }), null) + assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000, total: 0, pages: 0 }), null) assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath')) assert.match(source, /form\.append\('file'/) assert.doesNotMatch(source, /form\.append\('palletId'/) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 8b33d1632..0ced75364 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -214,13 +214,21 @@ export function palletOptionsPath(tenantId, inboundId, keyword = '') { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?${params.toString()}` } -export function weightPalletOptionRecords(page) { +export function normalizeWeightPalletOptionPage(page) { if (!page || Array.isArray(page) || !Array.isArray(page.records)) return null - if (!Number.isInteger(Number(page.page)) || Number(page.page) < 1) return null - if (!Number.isInteger(Number(page.size)) || Number(page.size) < 1 - || Number(page.size) > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null - if (page.records.length > Number(page.size)) return null - return page.records + const normalized = Object.fromEntries(['page', 'size', 'total', 'pages'] + .map((key) => [key, Number(page[key])])) + if (!Number.isInteger(normalized.page) || normalized.page < 1) return null + if (!Number.isInteger(normalized.size) || normalized.size < 1 + || normalized.size > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null + if (!Number.isInteger(normalized.total) || normalized.total < 0) return null + if (!Number.isInteger(normalized.pages) || normalized.pages < 0) return null + if (page.records.length > normalized.size) return null + return { records: page.records, ...normalized } +} + +export function weightPalletOptionRecords(page) { + return normalizeWeightPalletOptionPage(page)?.records ?? null } async function createUploadedResult(context, target, pallet, image, overrides = {}) { @@ -401,7 +409,12 @@ export async function run(context) { ) const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse, `inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS) - const palletOptions = weightPalletOptionRecords(palletOptionsPage) + const normalizedPalletOptionsPage = normalizeWeightPalletOptionPage(palletOptionsPage) + const palletOptions = normalizedPalletOptionsPage?.records ?? null + context.assert(MODULE, '托盘候选成功响应为正式分页对象且数字字符串可归一化', palletOptionsResponse, + normalizedPalletOptionsPage !== null, + `page=${palletOptionsPage?.page ?? 'missing'}, size=${palletOptionsPage?.size ?? 'missing'}, total=${palletOptionsPage?.total ?? 'missing'}, pages=${palletOptionsPage?.pages ?? 'missing'}`, + NEW_CONTRACT_OPTIONS) const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0 && palletOptions.every(palletSnapshotComplete) context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse, diff --git a/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs new file mode 100644 index 000000000..9c5aec34e --- /dev/null +++ b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { normalizeWeightPalletOptionPage } from './inbound-weight-ocr.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java') +const pageResult = read('../../common-core/src/main/java/com/baiyue/common/core/api/PageResult.java') +const longJackson = read('../../common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java') +const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml') +const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts') +const adminApiTests = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.test.ts') +const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') +const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts') + +function endpointBlock(source, marker, nextMarker) { + const start = source.indexOf(marker) + assert.notEqual(start, -1, `missing marker: ${marker}`) + const end = source.indexOf(nextMarker, start + marker.length) + return source.slice(start, end === -1 ? source.length : end) +} + +test('formal success accepts two records and string pagination numbers', () => { + const records = [{ palletId: '1' }, { palletId: '2' }] + assert.deepEqual(normalizeWeightPalletOptionPage({ records, page: '1', size: '20', total: '2', pages: '1' }), + { records, page: 1, size: 20, total: 2, pages: 1 }) +}) + +test('formal empty page remains a successful page instead of an error', () => { + assert.deepEqual(normalizeWeightPalletOptionPage({ records: [], page: '1', size: '20', total: '0', pages: '0' }), + { records: [], page: 1, size: 20, total: 0, pages: 0 }) +}) + +test('malformed array and invalid page metadata are rejected by the HTTP contract', () => { + assert.equal(normalizeWeightPalletOptionPage([]), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 20, total: -1, pages: 0 }), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 101, total: 0, pages: 0 }), null) +}) + +test('Controller DTO and response envelope use PageResult records/page/size/total/pages', () => { + const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit') + assert.match(block, /ApiResponse>/) + assert.match(pageResult, /record PageResult\(List records, long page, long size, long total, long pages\)/) +}) + +test('OpenAPI documents the page object and permits runtime string pagination numbers', () => { + const endpoint = endpointBlock(openapi, + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:', + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:') + assert.match(endpoint, /data: \{\$ref: '#\/components\/schemas\/WeightPalletOptionPage'\}/) + const schema = endpointBlock(openapi, ' WeightPalletOptionPage:', ' CreateWeightResultRequest:') + assert.match(schema, /required: \[records, page, size, total, pages\]/) + for (const field of ['page', 'size', 'total', 'pages']) { + assert.match(schema, new RegExp(`${field}:[^\n]*(?:oneOf|string)`), + `${field} must permit the string form emitted by the shared Long serializer`) + } + assert.match(longJackson, /serializerByType\(Long\.TYPE, ToStringSerializer\.instance\)/) +}) + +test('Admin reads records, normalizes numeric strings, and never treats data as an array', () => { + const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage') + assert.match(block, /ApiEnvelope/) + assert.match(block, /records:\s*page\.records/) + assert.match(block, /normalizedPageNumber\(page\.(?:page|size|total|pages)/) + assert.doesNotMatch(block, /ApiEnvelope/) + assert.match(adminPanel, /palletOptions\.value = result\.records/) +}) + +test('Admin behavior covers two-record success and an empty successful page', () => { + assert.match(adminApiTests, /formal paged pallet response[\s\S]{0,900}records[\s\S]{0,300}page: '1'/) + assert.match(adminBehavior, /no pallet options[\s\S]{0,500}palletPage\(\[\]\)/) +}) + +test('Admin behavior exposes a body code business error as a candidate load failure', () => { + assert.match(adminBehavior, /getWeightPalletOptions\)\.mockRejectedValueOnce\(new ApiResponseError\([^)]*'A0500'/) + assert.match(adminBehavior, /可选托盘加载失败|托盘服务暂不可用/) +}) + +test('Admin behavior separately covers a non-2xx HTTP or network error', () => { + assert.match(adminBehavior, /(?:HTTP error|HTTP错误|network error|网络错误)[\s\S]{0,700}(?:AxiosError|HTTP_5|status)/i) +}) + +test('Admin aborts and ignores an expired candidate response', () => { + assert.match(adminBehavior, /in-flight pallet request[\s\S]{0,900}signal\?\.aborted[\s\S]{0,300}过期托盘/) +}) From 0a3ec0b8a63e05c3f6914395dbd0008b42d92062 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 14:30:23 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 8 +- .../service/PdaPickSettlementService.java | 22 +++- .../service/LoadingPickServiceTest.java | 57 +++++++++ .../service/PdaPickSettlementServiceTest.java | 9 +- ...tion-release-convergence-contract.test.mjs | 72 +++++++++++ .../warehouse/mapper/LocationMapper.java | 10 ++ .../mapper/LocationReservationMapper.java | 10 ++ .../repository/LocationRepository.java | 12 ++ .../service/LocationResourceService.java | 121 +++++++++++++----- .../main/resources/db/phase2-pallet-seed.sql | 8 +- 10 files changed, 283 insertions(+), 46 deletions(-) create mode 100644 scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1129fb310..ea626c471 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6473,9 +6473,7 @@ ON DUPLICATE KEY UPDATE location_type = VALUES(location_type), max_weight_kg = VALUES(max_weight_kg), shared_overflow = VALUES(shared_overflow), - status = 'AVAILABLE', - remark = VALUES(remark), - deleted = 0; + remark = VALUES(remark); INSERT INTO wh_location ( id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, @@ -6494,9 +6492,7 @@ ON DUPLICATE KEY UPDATE location_type = VALUES(location_type), max_weight_kg = VALUES(max_weight_kg), shared_overflow = VALUES(shared_overflow), - status = 'AVAILABLE', - remark = VALUES(remark), - deleted = 0; + remark = VALUES(remark); DROP PROCEDURE IF EXISTS assert_yiwu_location_seed; DELIMITER // diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index f7b6d0a8e..389b0c81f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -18,6 +18,8 @@ import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Comparator; @@ -32,6 +34,7 @@ import java.util.stream.Collectors; /** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */ @Service public class PdaPickSettlementService { + private static final Logger log = LoggerFactory.getLogger(PdaPickSettlementService.class); private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; private static final String SOURCE_SERVICE = "firstleg-service"; @@ -379,7 +382,7 @@ public class PdaPickSettlementService { private static List releasedReservationIds(List allocations) { return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId) - .sorted().toList(); + .distinct().sorted().toList(); } private static List remainingTransferItems( @@ -422,18 +425,35 @@ public class PdaPickSettlementService { operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId, releasedReservationIds, remainingItems)); } catch (FeignException exception) { + log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} " + + "httpStatus={} releasedReservationIds={} remainingReservations={}", + taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId, + exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems)); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源"); } if (response == null || !response.successful() || response.data() == null || response.data().released() == null || response.data().transferred() == null || !sameReleasedReservations(releasedReservationIds, response.data().released()) || !sameTransferredResources(remainingItems, response.data().transferred())) { + log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} " + + "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER " + + "releasedReservationIds={} remainingReservations={}", + taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId, + response == null ? null : response.code(), response == null ? null : response.requestId(), + response == null ? null : response.traceId(), releasedReservationIds, + settlementTransferFacts(remainingItems)); ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); throw new BizException(code, response == null || response.message() == null ? "仓储服务原子结算PDA库位资源失败" : response.message()); } } + private static List settlementTransferFacts( + List remainingItems) { + return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount()) + .sorted().toList(); + } + private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) { try { ApiResponse response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 437c62dcd..a1189044e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; @@ -613,6 +614,62 @@ class LoadingPickServiceTest { verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() { + LoadingPickTaskEntity task = task(); + task.setVersion(1); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "释放库位资源时发生并发变更")) + .when(settlementService).settle(20L, 9L, "operator"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("释放库位资源时发生并发变更"); + + verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString()); + verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); + verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).publishCompletedEvent(anyLong()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyLong(), anyString(), anyString(), anyString()); + } + + @Test + void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() { + LoadingPickTaskEntity task = task(); + task.setVersion(1); + LoadingPickItemEntity item = item(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + + Task result = service.complete(20L, new CompleteLoadingRequest(1, + "complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c", + "PDA确认装柜完成"), 9L, "operator"); + + assertThat(result.id()).isEqualTo(20L); + verify(settlementService).settle(20L, 9L, "operator"); + verify(repository).insert(org.mockito.ArgumentMatchers.argThat( + (LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType()))); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingListService).publishCompletedEvent(10L); + } + @Test void atomicallySubmitsCompleteBatchAndReplaysSameResult() { LoadingPickTaskEntity task = task(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 131a02042..9bf638824 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest { when(splits.lockParent(31L)).thenReturn(parent(20)); when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20))); - when(allocations.lockConfirmed(10001L, 31L)) - .thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10))); + InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10); + InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10); + first.setPalletId(801L); + second.setPalletId(801L); + when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second)); when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)), + new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)), List.of()))); writes(0, 20); diff --git a/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs new file mode 100644 index 000000000..5c7b012e8 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-complete-location-release-convergence-contract.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') + +const locationService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java') +const locationRepository = read('../../warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java') +const locationMapper = read('../../warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java') +const locationTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java') +const settlementService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java') +const settlementTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java') +const finalizeHttp = read('phase3-loading-finalize.mjs') + +function releaseDecision({ sharedOverflow, locationStatus, activeOccupanciesAfterRelease }) { + if (sharedOverflow) return 'SHARED_STATUS_UNCHANGED' + if (activeOccupanciesAfterRelease > 0) return 'KEEP_OCCUPIED' + if (locationStatus === 'AVAILABLE') return 'ALREADY_AVAILABLE' + if (locationStatus === 'OCCUPIED') return 'TRANSITION_TO_AVAILABLE' + return 'CONFLICT' +} + +test('offline release decision converges active-occupancy/location drift', () => { + assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'AVAILABLE', activeOccupanciesAfterRelease: 0 }), + 'ALREADY_AVAILABLE') + assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'OCCUPIED', activeOccupanciesAfterRelease: 0 }), + 'TRANSITION_TO_AVAILABLE') + assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'OCCUPIED', activeOccupanciesAfterRelease: 1 }), + 'KEEP_OCCUPIED') + assert.equal(releaseDecision({ sharedOverflow: true, locationStatus: 'AVAILABLE', activeOccupanciesAfterRelease: 5 }), + 'SHARED_STATUS_UNCHANGED') +}) + +test('releaseSelectedReservations accepts AVAILABLE drift only after the last active occupancy is released', () => { + const block = locationService.slice(locationService.indexOf('releaseSelectedReservations('), + locationService.indexOf('transferToChild(', locationService.indexOf('releaseSelectedReservations('))) + assert.match(block, /releaseOccupancy/) + assert.match(block, /(?:count|has|exists|remaining)[A-Za-z]*(?:Active|Occupied|Occupanc)/i) + assert.match(block, /"AVAILABLE"\.equals\(location\.getStatus\(\)\)|location\.getStatus\(\).*"AVAILABLE"/s) + assert.match(block, /"OCCUPIED"/) + assert.match(block, /changeStatus/) + assert.doesNotMatch(block, + /releaseOccupancy[\s\S]{0,400}changeStatus\([^;]+"OCCUPIED",\s*"AVAILABLE"[^;]+\)\s*!=\s*1\)\s*\{/, + 'release must not unconditionally require OCCUPIED -> AVAILABLE after occupancy release') +}) + +test('repository can count or lock remaining active occupancies for one location', () => { + assert.match(locationRepository, /(?:count|has|exists|lock)[A-Za-z]*(?:Active|Occupied|Occupanc)[A-Za-z]*\(/i) + assert.match(locationMapper, /occupancy_status\s*=\s*'OCCUPIED'/i) + assert.match(locationMapper, /location_id/) +}) + +test('Warehouse unit tests lock all three non-shared convergence cases', () => { + assert.match(locationTests, /AVAILABLE[\s\S]{0,1200}(?:active occupancy|活动占用|drift|漂移)/i) + assert.match(locationTests, /(?:last|最后)[\s\S]{0,1200}(?:AVAILABLE|changeStatus)/i) + assert.match(locationTests, /(?:other|remaining|其他|仍有)[\s\S]{0,1200}(?:occupancy|占用)[\s\S]{0,600}(?:never\(\)\.changeStatus|OCCUPIED)/i) +}) + +test('same settlement key replays without a second release or transfer', () => { + assert.match(settlementService, /"COMPLETED"\.equals\(operation\.getOperationStatus\(\)\)[\s\S]{0,180}replaySettlementResult/) + assert.match(settlementTests, /shouldReplayCompletedPdaSettlementForSameIdempotencyKey/) + assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.releaseSelectedReservations/) + assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.transferToChildByReservation/) +}) + +test('HTTP failure contract proves task plan allocation location history outbox operation zero drift', () => { + for (const fact of ['task', 'plan', 'inbound', 'allocation', 'location', 'history', 'outbox', 'operation']) { + assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing ${fact} failure baseline`) + } + assert.match(finalizeHttp, /A0409/) + assert.match(finalizeHttp, /零写入|零漂移/) +}) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java index 929d465e4..2a1156f43 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationMapper.java @@ -109,6 +109,16 @@ public interface LocationMapper extends BaseMapper { @Param("warehouseId") long warehouseId, @Param("limit") int limit); + @Select(""" + SELECT * FROM wh_location + WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} + AND deleted = 0 + FOR UPDATE + """) + LocationEntity lockOwned(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("id") long id); + @Insert(""" INSERT INTO wh_location ( id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java index b53a7667e..a3e695d65 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationReservationMapper.java @@ -61,6 +61,16 @@ public interface LocationReservationMapper extends BaseMapper page(Long tenantId, PageQuery query) { Page result = locations.selectLocationPage(new Page<>(query.page(), query.size()), tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status()); @@ -94,6 +98,10 @@ public class LocationRepository { || occupancies.countActiveByLocation(tenantId, locationId) > 0; } + public int countActiveOccupancies(long tenantId, long locationId) { + return occupancies.countActiveByLocation(tenantId, locationId); + } + public List findReservationsByKeys(long tenantId, String sourceService, Collection keys) { if (keys.isEmpty()) { @@ -129,6 +137,10 @@ public class LocationRepository { return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason); } + public int releaseConfirmedReservation(LocationReservationEntity entity, String reason) { + return reservations.releaseConfirmed(entity.getTenantId(), entity.getId(), entity.getVersion(), reason); + } + public int transferConfirmedReservation(LocationReservationEntity entity, String sourceService, long sourceBusinessId, long targetBusinessId, String targetInboundCode) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index 3ef390192..f0fb24c5b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -24,6 +24,8 @@ import com.baiyue.warehouse.entity.LocationReservationEntity; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.LocationRepository; import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,6 +44,7 @@ import java.util.LinkedHashMap; import java.util.stream.Collectors; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -51,6 +54,8 @@ import java.util.Set; @Service @Validated public class LocationResourceService { + private static final Logger log = LoggerFactory.getLogger(LocationResourceService.class); + @Resource private LocationRepository repository; @Resource @@ -165,13 +170,8 @@ public class LocationResourceService { if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) { continue; } - LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId()); - if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 - || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) - && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), - "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { - throw new BizException(ResultCode.CONFLICT, "释放占用时库位状态已被并发修改"); - } + releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), + "释放占用时库位状态已被并发修改"); released.add(active(occupancy, "RELEASED", null)); } for (LocationReservationEntity reservation : repository.findReservationsByBusiness( @@ -237,15 +237,8 @@ public class LocationResourceService { List released = new ArrayList<>(selectedIndexes.size()); for (int index : selectedIndexes) { LocationOccupancyEntity occupancy = active.get(index); - LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), - occupancy.getLocationId()); - if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 - || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) - && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), - "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { - throw new BizException(ResultCode.CONFLICT, - "Location occupancy changed concurrently while releasing selected locations"); - } + releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), + "Location occupancy changed concurrently while releasing selected locations"); released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), packages.get(index))); @@ -289,15 +282,8 @@ public class LocationResourceService { throw new BizException(ResultCode.CONFLICT, "Occupied location has no valid allocated package count and cannot be released exactly"); } - LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), - occupancy.getLocationId()); - if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 - || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) - && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), - "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { - throw new BizException(ResultCode.CONFLICT, - "Location occupancy changed concurrently while releasing selected locations"); - } + releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), + "Location occupancy changed concurrently while releasing selected locations"); released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocatedPackageCount)); @@ -322,19 +308,94 @@ public class LocationResourceService { LocationOccupancyEntity occupancy = active.get(index); if (!requested.contains(occupancy.getReservationId())) continue; Integer allocated = occupancy.getAllocatedPackageCount(); - LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId()); - if (allocated == null || allocated < 0 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 - || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(), - occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(), - command.operatorId()) != 1)) { + if (allocated == null || allocated < 0) { throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更"); } + releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), "释放库位资源时发生并发变更"); result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated)); } return List.copyOf(result); } + /** + * Occupancy, confirmed reservation and the materialized location status form one resource fact. + * Lock the location before changing either fact so the final availability decision is serialized. + */ + private void releaseOccupiedResource(LocationOccupancyEntity occupancy, String reason, long operatorId, + String conflictMessage) { + LocationEntity location = repository.lockOwned(occupancy.getTenantId(), occupancy.getWarehouseId(), + occupancy.getLocationId()); + LocationReservationEntity reservation = repository.lockReservation( + occupancy.getTenantId(), occupancy.getReservationId()); + if (!matchesConfirmedResource(occupancy, reservation) || location == null) { + warnReleaseConflict(occupancy, location, reservation, 0, 0, -1, "RESOURCE_FACT_MISMATCH"); + throw new BizException(ResultCode.CONFLICT, conflictMessage); + } + + int occupancyAffected = repository.releaseOccupancy(occupancy, reason); + if (occupancyAffected != 1) { + warnReleaseConflict(occupancy, location, reservation, occupancyAffected, 0, -1, + "OCCUPANCY_UPDATE_MISSED"); + throw new BizException(ResultCode.CONFLICT, conflictMessage); + } + int reservationAffected = repository.releaseConfirmedReservation(reservation, reason); + if (reservationAffected != 1) { + warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected, -1, + "RESERVATION_UPDATE_MISSED"); + throw new BizException(ResultCode.CONFLICT, conflictMessage); + } + + if (Boolean.TRUE.equals(location.getSharedOverflow())) { + return; + } + int remainingActive = repository.countActiveOccupancies(occupancy.getTenantId(), occupancy.getLocationId()); + if (remainingActive > 0) { + warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected, + remainingActive, "NON_SHARED_LOCATION_STILL_OCCUPIED"); + throw new BizException(ResultCode.CONFLICT, conflictMessage); + } + if ("AVAILABLE".equals(location.getStatus())) { + log.warn("warehouse_location_release_converged reservationId={} occupancyId={} locationId={} " + + "expectedLocationStatus=OCCUPIED actualLocationStatus={} occupancyAffected={} " + + "reservationAffected={} remainingActiveOccupancies={}", + occupancy.getReservationId(), occupancy.getId(), occupancy.getLocationId(), location.getStatus(), + occupancyAffected, reservationAffected, remainingActive); + return; + } + if (!"OCCUPIED".equals(location.getStatus()) + || repository.changeStatus(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), + "OCCUPIED", "AVAILABLE", location.getVersion(), operatorId) != 1) { + warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected, + remainingActive, "LOCATION_STATUS_UPDATE_MISSED"); + throw new BizException(ResultCode.CONFLICT, conflictMessage); + } + } + + private static boolean matchesConfirmedResource(LocationOccupancyEntity occupancy, + LocationReservationEntity reservation) { + return reservation != null + && "CONFIRMED".equals(reservation.getReservationStatus()) + && Objects.equals(reservation.getWarehouseId(), occupancy.getWarehouseId()) + && Objects.equals(reservation.getBusinessId(), occupancy.getBusinessId()) + && Objects.equals(reservation.getLocationId(), occupancy.getLocationId()) + && Objects.equals(reservation.getPalletId(), occupancy.getPalletId()) + && Objects.equals(reservation.getSourceService(), occupancy.getSourceService()); + } + + private static void warnReleaseConflict(LocationOccupancyEntity occupancy, LocationEntity location, + LocationReservationEntity reservation, int occupancyAffected, + int reservationAffected, int remainingActive, String cause) { + log.warn("warehouse_location_release_conflict cause={} reservationId={} occupancyId={} locationId={} " + + "expectedReservationStatus=CONFIRMED actualReservationStatus={} " + + "expectedOccupancyStatus=OCCUPIED actualOccupancyStatus={} " + + "expectedLocationStatus=OCCUPIED actualLocationStatus={} occupancyAffected={} " + + "reservationAffected={} remainingActiveOccupancies={}", + cause, occupancy.getReservationId(), occupancy.getId(), occupancy.getLocationId(), + reservation == null ? null : reservation.getReservationStatus(), occupancy.getOccupancyStatus(), + location == null ? null : location.getStatus(), occupancyAffected, reservationAffected, remainingActive); + } + /** * 将未拣货的已占用库位从主入库单转给二级子入库单。 * 转移只改变业务归属和剩余箱数,不释放库位;托盘仅作为关联快照。 diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql index 82580ebb0..69c6aa626 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-seed.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql @@ -85,9 +85,7 @@ ON DUPLICATE KEY UPDATE location_type=VALUES(location_type), max_weight_kg=VALUES(max_weight_kg), shared_overflow=VALUES(shared_overflow), - status='AVAILABLE', - remark=VALUES(remark), - deleted=0; + remark=VALUES(remark); INSERT INTO wh_location( id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name, @@ -106,9 +104,7 @@ ON DUPLICATE KEY UPDATE location_type=VALUES(location_type), max_weight_kg=VALUES(max_weight_kg), shared_overflow=VALUES(shared_overflow), - status='AVAILABLE', - remark=VALUES(remark), - deleted=0; + remark=VALUES(remark); DROP PROCEDURE IF EXISTS assert_yiwu_location_seed; DELIMITER // From ed3c1dafccd70d4150a8645acca1fdb14f31fcdb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 14:30:33 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lWarehouseResourceSeedSqlContractTest.java | 4 + .../service/LocationResourceServiceTest.java | 119 +++++++++++++++--- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index 41f33aed0..72e561605 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -50,6 +50,10 @@ class InitialWarehouseResourceSeedSqlContractTest { .contains("(" + idPrefix + "204,0," + dongguanId + ",'A','A区','A0'") .contains("shared_overflow") .contains("'AVAILABLE'") + .doesNotContain("status='AVAILABLE',\n remark=VALUES(remark)") + .doesNotContain("status = 'AVAILABLE',\n remark = VALUES(remark)") + .doesNotContain("remark=VALUES(remark),\n deleted=0") + .doesNotContain("remark = VALUES(remark),\n deleted = 0") .doesNotContain("INSERT INTO wh_location_reservation") .doesNotContain("INSERT INTO wh_location_occupancy"); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index ecfe65978..f9c1d2d69 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -257,9 +257,7 @@ class LocationResourceServiceTest { item.setBusinessId(901L); }); when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active); - when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2")); - when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1); - when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1); + stubSuccessfulOccupiedRelease(active.get(1), occupiedLocation(10L, "A2"), "PDA partial pick", 7L); var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L, "PDA partial pick", 10, List.of(10L), null)); @@ -280,9 +278,7 @@ class LocationResourceServiceTest { LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L); second.setAllocatedPackageCount(7); when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second)); - when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2")); - when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1); - when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1); + stubSuccessfulOccupiedRelease(second, occupiedLocation(10L, "A2"), "PDA full pick", 7L); var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand( 10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L))); @@ -299,9 +295,7 @@ class LocationResourceServiceTest { LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L); occupancy.setAllocatedPackageCount(0); when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy)); - when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1")); - when(repository.releaseOccupancy(occupancy, "PDA full pick")).thenReturn(1); - when(repository.changeStatus(10001L, 88L, 9L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1); + stubSuccessfulOccupiedRelease(occupancy, occupiedLocation(9L, "A1"), "PDA full pick", 7L); var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand( 10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(9L))); @@ -323,11 +317,8 @@ class LocationResourceServiceTest { }); when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active); for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) { - when(repository.findOwned(0L, 88L, item.getLocationId())) - .thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode())); - when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1); - when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L)) - .thenReturn(1); + stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()), + "PDA default pick", 7L); } var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L, @@ -348,11 +339,8 @@ class LocationResourceServiceTest { when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active); for (int index = 0; index < 2; index++) { LocationOccupancyEntity item = active.get(index); - when(repository.findOwned(10001L, 88L, item.getLocationId())) - .thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode())); - when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1); - when(repository.changeStatus(10001L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L)) - .thenReturn(1); + stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()), + "PDA default pick", 7L); } var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L, @@ -379,7 +367,8 @@ class LocationResourceServiceTest { void shouldFailOnConcurrentOccupancyUpdate() { LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); - when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1")); + when(repository.lockOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1")); + when(repository.lockReservation(10001L, 101L)).thenReturn(confirmedReservationFor(active)); when(repository.releaseOccupancy(active, "PDA pick")).thenReturn(0); assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand( @@ -389,6 +378,64 @@ class LocationResourceServiceTest { any(String.class), any(String.class), anyInt(), anyLong()); } + @Test + void shouldConvergeDriftedAvailableLocationWhenLastOccupancyIsReleasedByReservation() { + LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); + active.setAllocatedPackageCount(6); + LocationEntity drifted = location(9L, "A1"); + drifted.setVersion(2); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); + stubSuccessfulOccupiedRelease(active, drifted, "PDA complete", 7L); + + var result = service.releaseSelectedReservations(new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L))); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.reservationId()).isEqualTo(101L); + assertThat(item.locationId()).isEqualTo(9L); + }); + verify(repository).releaseConfirmedReservation(any(LocationReservationEntity.class), org.mockito.Mockito.eq("PDA complete")); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + + @Test + void shouldRejectAvailableNonSharedLocationWhenAnotherOccupancyRemains() { + LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); + when(repository.lockOwned(10001L, 88L, 9L)).thenReturn(location(9L, "A1")); + LocationReservationEntity reservation = confirmedReservationFor(active); + when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); + when(repository.releaseOccupancy(active, "PDA complete")).thenReturn(1); + when(repository.releaseConfirmedReservation(reservation, "PDA complete")).thenReturn(1); + when(repository.countActiveOccupancies(10001L, 9L)).thenReturn(1); + + assertThatThrownBy(() -> service.releaseSelectedReservations( + new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + + @Test + void shouldReleaseOneSharedOverflowAllocationWithoutOpeningOrMergingTheOther() { + LocationOccupancyEntity active = occupancy(201L, 101L, 99L, "A0", 501L); + active.setSharedOverflow(true); + active.setAllocatedPackageCount(4); + LocationEntity shared = overflow(); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); + stubSuccessfulOccupiedRelease(active, shared, "PDA complete", 7L); + + assertThat(service.releaseSelectedReservations( + new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L)))) + .singleElement().extracting(item -> item.reservationId()).isEqualTo(101L); + verify(repository, never()).countActiveOccupancies(anyLong(), anyLong()); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + @Test void shouldTransferConfirmedOccupiedResourcesToChildInbound() { LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L); @@ -514,4 +561,36 @@ class LocationResourceServiceTest { reservation.setVersion(0); return reservation; } + + private void stubSuccessfulOccupiedRelease(LocationOccupancyEntity occupancy, LocationEntity location, + String reason, long operatorId) { + location.setTenantId(occupancy.getTenantId()); + LocationReservationEntity reservation = confirmedReservationFor(occupancy); + when(repository.lockOwned(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId())) + .thenReturn(location); + when(repository.lockReservation(occupancy.getTenantId(), occupancy.getReservationId())).thenReturn(reservation); + when(repository.releaseOccupancy(occupancy, reason)).thenReturn(1); + when(repository.releaseConfirmedReservation(reservation, reason)).thenReturn(1); + if (!Boolean.TRUE.equals(location.getSharedOverflow())) { + when(repository.countActiveOccupancies(occupancy.getTenantId(), occupancy.getLocationId())).thenReturn(0); + if ("OCCUPIED".equals(location.getStatus())) { + when(repository.changeStatus(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), + "OCCUPIED", "AVAILABLE", location.getVersion(), operatorId)).thenReturn(1); + } + } + } + + private static LocationReservationEntity confirmedReservationFor(LocationOccupancyEntity occupancy) { + LocationReservationEntity reservation = reservation("CONFIRMED"); + reservation.setId(occupancy.getReservationId()); + reservation.setTenantId(occupancy.getTenantId()); + reservation.setWarehouseId(occupancy.getWarehouseId()); + reservation.setSourceService(occupancy.getSourceService()); + reservation.setBusinessId(occupancy.getBusinessId()); + reservation.setPalletId(occupancy.getPalletId()); + reservation.setLocationId(occupancy.getLocationId()); + reservation.setLocationCode(occupancy.getLocationCode()); + reservation.setSharedOverflow(occupancy.getSharedOverflow()); + return reservation; + } } From d7bff980ca6f99477c290ef11744b71badc31dce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 14:53:27 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/web/GlobalExceptionHandler.java | 9 + .../web/GlobalExceptionHandlerTest.java | 13 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 48 +++++ .../LoadingCompletionCommandEntity.java | 36 ++++ .../LoadingCompletionCommandMapper.java | 106 +++++++++++ .../mapper/LoadingPickTaskMapper.java | 27 ++- .../LoadingCompletionCommandRepository.java | 33 ++++ .../repository/LoadingPickRepository.java | 12 ++ .../service/LoadingCompletionWorker.java | 64 +++++++ .../firstleg/service/LoadingPickService.java | 169 +++++++++++++++--- .../service/PdaPickSettlementService.java | 49 ++++- .../db/phase3-smart-loading-schema.sql | 53 +++++- .../openapi/phase3-pda-convergence-v1.yaml | 12 +- .../LoadingCompletionSqlContractTest.java | 32 ++++ .../service/LoadingCompletionWorkerTest.java | 74 ++++++++ .../service/LoadingPickServiceTest.java | 165 +++++++++++++++++ ...e-cross-service-recovery-contract.test.mjs | 123 +++++++++++++ ...lWarehouseResourceSeedSqlContractTest.java | 12 +- 18 files changed, 991 insertions(+), 46 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java create mode 100644 scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 652028ab7..8df2058e0 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.dao.CannotAcquireLockException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -96,6 +97,14 @@ public class GlobalExceptionHandler { return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request); } + @ExceptionHandler(CannotAcquireLockException.class) + public ResponseEntity> handleCannotAcquireLock( + CannotAcquireLockException exception, HttpServletRequest request) { + log.warn("Database lock acquisition conflict requestId={} traceId={}", + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unhandled request failure requestId={} traceId={}", diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index c932c7d96..c98f8c305 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -16,6 +16,7 @@ import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import org.springframework.dao.CannotAcquireLockException; import java.lang.reflect.Method; @@ -116,6 +117,18 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code()); } + @Test + void lockWaitTimeoutMapsToCanonicalConflict() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout"); + + var response = handler.handleCannotAcquireLock( + new CannotAcquireLockException("Lock wait timeout exceeded"), request); + + assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code()); + assertThat(response.getBody().requestId()).isEqualTo("lock-timeout"); + } + private void paging(long page) { } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ea626c471..b1fb48862 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16506,6 +16506,54 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( KEY idx_customer_dimension_task_diagnosis (status,updated_at) ) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; +USE baiyue_firstleg; +ALTER TABLE fl_loading_pick_task MODIFY COLUMN task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' + COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED'; +ALTER TABLE fl_loading_pick_task DROP CHECK chk_loading_pick_task_status; +ALTER TABLE fl_loading_pick_task ADD CONSTRAINT chk_loading_pick_task_status + CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED')); + +CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( + id BIGINT UNSIGNED NOT NULL,pick_task_id BIGINT UNSIGNED NOT NULL,operation_id VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL,command_status VARCHAR(16) NOT NULL,expected_task_version INT UNSIGNED NOT NULL, + remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL, + attempt_count INT UNSIGNED NOT NULL DEFAULT 0,next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_owner VARCHAR(128) NULL,lease_until DATETIME(3) NULL,failure_stage VARCHAR(40) NULL, + failure_code VARCHAR(32) NULL,failure_request_id VARCHAR(64) NULL,failure_trace_id VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL, + PRIMARY KEY(id),UNIQUE KEY uk_loading_completion_operation(operation_id), + KEY idx_loading_completion_dispatch(command_status,next_retry_at,lease_until), + CONSTRAINT fk_loading_completion_task FOREIGN KEY(pick_task_id) REFERENCES fl_loading_pick_task(id), + CONSTRAINT chk_loading_completion_status CHECK(command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令'; +CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint ( + command_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL, + owner_sequence INT UNSIGNED NOT NULL,checkpoint_stage VARCHAR(24) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(command_id,tenant_id,inbound_id),KEY idx_loading_completion_owner(command_id,owner_sequence), + CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY(command_id) REFERENCES fl_loading_completion_command(id), + CONSTRAINT chk_loading_completion_checkpoint CHECK(checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点'; + +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update BEFORE UPDATE ON fl_loading_pick_task FOR EACH ROW +BEGIN + IF OLD.task_status='COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已完成PDA拣货任务不可修改'; END IF; + IF NEW.loading_plan_id<>OLD.loading_plan_id OR NOT(NEW.container_booking_id<=>OLD.container_booking_id) + OR NOT(NEW.cabinet_no<=>OLD.cabinet_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务稳定归属不可修改'; + END IF; + IF NOT ((OLD.task_status='LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status='COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status='COMPLETION_FAILED' AND NEW.task_status='COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务状态流转非法'; + END IF; +END$$ +DELIMITER ; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java new file mode 100644 index 000000000..29d6acd2b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** Durable PDA completion command. Cross-service work is never executed in the acceptance transaction. */ +@Getter +@Setter +@TableName("fl_loading_completion_command") +public class LoadingCompletionCommandEntity { + @TableId(type = IdType.INPUT) private Long id; + private Long pickTaskId; + private String operationId; + private String requestHash; + private String commandStatus; + private Integer expectedTaskVersion; + private String remark; + private Long operatorId; + private String operatorName; + private Integer attemptCount; + private LocalDateTime nextRetryAt; + private String leaseOwner; + private LocalDateTime leaseUntil; + private String failureStage; + private String failureCode; + private String failureRequestId; + private String failureTraceId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime completedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java new file mode 100644 index 000000000..777523447 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java @@ -0,0 +1,106 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface LoadingCompletionCommandMapper extends BaseMapper { + @Insert(""" + INSERT IGNORE INTO fl_loading_completion_command + (id,pick_task_id,operation_id,request_hash,command_status,expected_task_version,remark, + operator_id,operator_name,attempt_count,next_retry_at) + VALUES + (#{id},#{pickTaskId},#{operationId},#{requestHash},'PROCESSING',#{expectedTaskVersion},#{remark}, + #{operatorId},#{operatorName},0,CURRENT_TIMESTAMP(3)) + """) + int insertCommand(LoadingCompletionCommandEntity command); + + @Select("SELECT * FROM fl_loading_completion_command WHERE operation_id=#{operationId}") + LoadingCompletionCommandEntity findByOperation(@Param("operationId") String operationId); + + @Select(""" + SELECT * FROM fl_loading_completion_command + WHERE pick_task_id=#{taskId} AND command_status IN ('PROCESSING','RETRYABLE','FAILED') + ORDER BY created_at DESC LIMIT 1 + """) + LoadingCompletionCommandEntity findActiveByTask(@Param("taskId") long taskId); + + @Select(""" + SELECT * FROM fl_loading_completion_command + WHERE command_status IN ('PROCESSING','RETRYABLE') AND next_retry_at<=CURRENT_TIMESTAMP(3) + AND (lease_until IS NULL OR lease_until findDue(@Param("limit") int limit); + + @Update(""" + UPDATE fl_loading_completion_command + SET command_status='PROCESSING',lease_owner=#{worker},lease_until=#{leaseUntil}, + attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND command_status IN ('PROCESSING','RETRYABLE') + AND next_retry_at<=CURRENT_TIMESTAMP(3) + AND (lease_until IS NULL OR lease_until + "AND task_status='LOADING' AND version=#{expectedVersion}") int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion); + @Update(""" + UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1 + WHERE id=#{taskId} AND task_status='LOADING' AND version=#{expectedVersion} + """) + int acceptCompletion(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion); + + @Update(""" + UPDATE fl_loading_pick_task + SET task_status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, + completed_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{taskId} AND task_status='COMPLETING' + """) + int completeAccepted(@Param("taskId") long taskId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update("UPDATE fl_loading_pick_task SET task_status='COMPLETION_FAILED',version=version+1 " + + "WHERE id=#{taskId} AND task_status='COMPLETING'") + int failCompletion(@Param("taskId") long taskId); + + @Update("UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1 " + + "WHERE id=#{taskId} AND task_status='COMPLETION_FAILED'") + int resumeCompletion(@Param("taskId") long taskId); + @Select(""" SELECT DISTINCT task.* FROM fl_loading_pick_task task @@ -60,6 +83,8 @@ public interface LoadingPickTaskMapper extends BaseMapper booking.merged_into_container_booking_id AS mergedIntoContainerBookingId, booking.warehouse_name AS warehouseName, task.task_status AS taskStatus, CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED' + WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING' + WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED' WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0 THEN 'AWAITING_REVIEW' @@ -72,7 +97,7 @@ public interface LoadingPickTaskMapper extends BaseMapper JOIN fl_container_booking booking ON booking.id = plan.container_booking_id JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id - WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE' + WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE' AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}) AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java new file mode 100644 index 000000000..0e73e8ce7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; +import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class LoadingCompletionCommandRepository { + @Resource private LoadingCompletionCommandMapper mapper; + + public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); } + public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); } + public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); } + public List due(int limit) { return mapper.findDue(limit); } + public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; } + public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; } + public boolean complete(long id, String worker) { return mapper.complete(id, worker) == 1; } + public boolean fail(long id, String worker, String status, LocalDateTime nextRetryAt, String stage, + String code, String requestId, String traceId) { + return mapper.fail(id, worker, status, nextRetryAt, stage, code, requestId, traceId) == 1; + } + public boolean resume(long id) { return mapper.resume(id) == 1; } + public String checkpoint(long commandId, long tenantId, long inboundId) { + return mapper.checkpoint(commandId, tenantId, inboundId); + } + public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) { + mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 683f1e174..d4489ef74 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -57,6 +57,18 @@ public class LoadingPickRepository { return tasks.touch(taskId, expectedVersion); } + public int acceptCompletion(long taskId, int expectedVersion) { + return tasks.acceptCompletion(taskId, expectedVersion); + } + + public int completeAccepted(long taskId, long operatorId, String operatorName) { + return tasks.completeAccepted(taskId, operatorId, operatorName); + } + + public int failCompletion(long taskId) { return tasks.failCompletion(taskId); } + + public int resumeCompletion(long taskId) { return tasks.resumeCompletion(taskId); } + public LoadingPickTaskEntity taskByPlan(long planId) { return tasks.selectOne(new LambdaQueryWrapper() .eq(LoadingPickTaskEntity::getLoadingPlanId, planId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java new file mode 100644 index 000000000..412ebbfcc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java @@ -0,0 +1,64 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** Lease-based recovery worker for PDA completion commands. */ +@Service +public class LoadingCompletionWorker { + private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class); + private static final int MAX_ATTEMPTS = 5; + private final String workerId = "pda-completion-" + UUID.randomUUID(); + + @Resource private LoadingCompletionCommandRepository commands; + @Resource private LoadingPickRepository picks; + @Resource private LoadingPickService loadingPicks; + + @Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") + public void dispatch() { + for (LoadingCompletionCommandEntity candidate : commands.due(10)) { + if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) { + continue; + } + try { + loadingPicks.processCompletion(candidate, workerId); + } catch (RuntimeException exception) { + handleFailure(candidate, exception); + } + } + } + + private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) { + Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException + && exception.getCause() != null ? exception.getCause() : exception; + String stage = exception instanceof LoadingPickService.CompletionProcessingException staged + ? staged.stage() : "UNEXPECTED"; + String code = cause instanceof BizException business + ? business.getResultCode().code() : "S0500"; + String requestId = cause instanceof PdaPickSettlementService.SettlementFailure remote + ? remote.requestId() : null; + String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote + ? remote.traceId() : null; + int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1; + boolean terminal = attempt >= MAX_ATTEMPTS; + LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4))); + boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt, + stage, code, requestId, traceId); + if (terminal && recorded) { + picks.failCompletion(command.getPickTaskId()); + } + log.warn("pda_completion_worker_failure commandId={} taskId={} operationId={} stage={} code={} " + + "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(), + command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 1dcd6ad1a..e0eb96dd0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -26,6 +26,7 @@ import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; @@ -39,6 +40,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; @@ -66,6 +68,7 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.time.LocalDateTime; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; /** 装柜中 PDA 拣货任务及明细同步。 */ @Service @@ -76,6 +79,8 @@ public class LoadingPickService { @Resource private LoadingPickRepository repository; @Resource + private LoadingCompletionCommandRepository completionCommands; + @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private OceanBookingRepository oceanBookingRepository; @@ -440,9 +445,31 @@ public class LoadingPickService { String operationId = operation(request.operationId()); String remark = trim(request.remark()); String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark); - Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); - if (replay != null) return replay; - LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); + LoadingCompletionCommandEntity replay = completionCommands.operation(operationId); + if (replay != null) { + requireSameCompletionCommand(replay, taskId, requestHash, operatorId); + if ("FAILED".equals(replay.getCommandStatus())) { + LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId); + if (!"COMPLETION_FAILED".equals(failed.getTaskStatus()) + || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { + throw conflict("装柜完成命令状态已变化,请刷新后重试"); + } + } + return view(requiredTask(taskId), null); + } + LoadingPickTaskEntity task = requiredTaskForUpdate(taskId); + LoadingCompletionCommandEntity concurrentReplay = completionCommands.operation(operationId); + if (concurrentReplay != null) { + requireSameCompletionCommand(concurrentReplay, taskId, requestHash, operatorId); + return view(task, null); + } + LoadingCompletionCommandEntity active = completionCommands.active(taskId); + if (active != null) { + throw conflict("已有其他装柜完成命令正在处理"); + } + if (!"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务正在完成或已经完成"); + } if (!request.expectedTaskVersion().equals(task.getVersion())) { throw conflict("PDA任务版本已变化,请刷新后复核"); } @@ -454,40 +481,122 @@ public class LoadingPickService { .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .toList(); if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); - LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0); - List owners = discoveredOwners.stream() + LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity(); + command.setId(IdWorker.getId()); + command.setPickTaskId(taskId); + command.setOperationId(operationId); + command.setRequestHash(requestHash); + command.setExpectedTaskVersion(request.expectedTaskVersion()); + command.setRemark(remark); + command.setOperatorId(operatorId); + command.setOperatorName(trim(operatorName)); + if (completionCommands.insert(command) != 1) { + LoadingCompletionCommandEntity concurrent = completionCommands.operation(operationId); + requireSameCompletionCommand(concurrent, taskId, requestHash, operatorId); + return view(requiredTask(taskId), null); + } + if (repository.acceptCompletion(taskId, task.getVersion()) != 1) { + throw conflict("PDA任务版本已变化,请刷新后复核"); + } + return view(requiredTask(taskId), null); + } + + /** Called only by the completion worker, after its durable lease has committed. */ + public void processCompletion(LoadingCompletionCommandEntity command, String worker) { + long taskId = command.getPickTaskId(); + LoadingPickTaskEntity task = requiredTask(taskId); + List owners = repository.inboundOwners(taskId).stream() + .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .toList(); - for (LoadingPickItemMapper.InboundOwner owner : owners) { - LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); - if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); - completionStage(taskId, "完成前业务门禁", () -> - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), - evidenceOwner.inboundId())); + if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); + long evidenceInboundId = owners.get(0).inboundId(); + for (int index = 0; index < owners.size(); index++) { + requireCompletionLease(command.getId(), worker); + LoadingPickItemMapper.InboundOwner owner = owners.get(index); + String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); + if (!checkpointAtLeast(checkpoint, "READY")) { + runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( + owner.tenantId(), owner.inboundId(), evidenceInboundId)); + requireCompletionLease(command.getId(), worker); + completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "READY"); + } + if (!checkpointAtLeast(checkpoint, "SETTLED")) { + runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( + taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName())); + requireCompletionLease(command.getId(), worker); + completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED"); + } + if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { + runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( + owner.tenantId(), owner.inboundId(), evidenceInboundId, command.getOperatorId(), + command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(), + command.getRemark())); + requireCompletionLease(command.getId(), worker); + completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, + "INBOUND_COMPLETED"); + } } - completionStage(taskId, "拆单与库位结算", () -> - settlementService.settle(taskId, operatorId, operatorName)); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { - throw conflict("柜次仍有未完成PDA拣货的箱数"); + throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数")); } - if (repository.approve(taskId, operatorId, operatorName, remark) != 1) { - throw conflict("PDA任务已经复核"); + if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(), + command.getOperatorName(), command.getRemark()) != 1) { + throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核")); } - insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName); - completionStage(taskId, "装柜计划完成", () -> - loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName)); - repository.syncInboundProjections(taskId, operatorId, operatorName); - for (LoadingPickItemMapper.InboundOwner owner : owners) { - completionStage(taskId, "入库单状态完成", () -> - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), - evidenceOwner.inboundId(), operatorId, operatorName, - "PDA-" + operationId + '-' + owner.inboundId(), remark)); + if (repository.operation(command.getOperationId()) == null) { + insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING", + command.getOperatorId(), command.getOperatorName()); } - completionStage(taskId, "完成事件发布", () -> - loadingListService.publishCompletedEvent(task.getLoadingPlanId())); - completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); - return view(requiredTask(taskId), null); + runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, + command.getOperatorId(), command.getOperatorName())); + repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName()); + runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); + requireCompletionLease(command.getId(), worker); + LoadingPickTaskEntity current = requiredTask(taskId); + if (!"COMPLETED".equals(current.getTaskStatus()) + && repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) { + throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化")); + } + if (!completionCommands.complete(command.getId(), worker)) { + throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化")); + } + completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName()); + } + + private void requireCompletionLease(long commandId, String worker) { + if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) { + throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化")); + } + } + + private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId, + String requestHash, long operatorId) { + if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId()) + || !Long.valueOf(operatorId).equals(command.getOperatorId()) + || !requestHash.equals(command.getRequestHash())) { + throw conflict("operationId已用于不同装柜完成命令"); + } + } + + private static boolean checkpointAtLeast(String checkpoint, String target) { + List stages = List.of("READY", "SETTLED", "INBOUND_COMPLETED"); + return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target); + } + + private static void runCompletionStage(String stage, Runnable action) { + try { + action.run(); + } catch (RuntimeException exception) { + throw new CompletionProcessingException(stage, exception); + } + } + + public static final class CompletionProcessingException extends RuntimeException { + private final String stage; + CompletionProcessingException(String stage, RuntimeException cause) { super(cause); this.stage = stage; } + public String stage() { return stage; } } private void completionStage(long taskId, String stage, Runnable action) { @@ -714,6 +823,8 @@ public class LoadingPickService { Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(), reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion()); String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED" + : "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING" + : "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED" : assigned == null ? "UNASSIGNED" : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; var booking = repository.activeBookingByTask(task.getId()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 389b0c81f..bf8847085 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -85,6 +85,35 @@ public class PdaPickSettlementService { } } + /** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */ + @Transactional + public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) { + if (!picks.allTaskItemsResolved(taskId)) { + throw conflict("PDA任务仍有未处理明细"); + } + List taskItems = picks.items(taskId); + Map pickedByInbound = pickedByInbound(taskItems); + Integer pickedPackageCount = pickedByInbound.get(inboundId); + if (pickedPackageCount == null) { + throw conflict("PDA任务不包含指定入库单"); + } + PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems); + for (Long planItemId : resolution.skippedPlanItemIds()) { + int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName); + if (changed != 0 && changed != 1) { + throw conflict("跳单明细已被并发修改,请刷新后重试"); + } + } + boolean newlySplit = settleInbound(inboundId, pickedPackageCount, taskItems, taskId, operatorId, operatorName); + if (newlySplit) { + for (PdaPickSettlementPlan.SplitLine line : resolution.partialByInbound() + .getOrDefault(inboundId, List.of())) { + requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName), + "部分拣货装柜明细"); + } + } + } + private boolean settleInbound(long parentInboundId, int pickedPackageCount, List taskItems, long taskId, long operatorId, String operatorName) { @@ -443,11 +472,27 @@ public class PdaPickSettlementService { response == null ? null : response.traceId(), releasedReservationIds, settlementTransferFacts(remainingItems)); ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); - throw new BizException(code, response == null || response.message() == null - ? "仓储服务原子结算PDA库位资源失败" : response.message()); + throw new SettlementFailure(code, response == null || response.message() == null + ? "仓储服务原子结算PDA库位资源失败" : response.message(), + response == null ? null : response.requestId(), response == null ? null : response.traceId()); } } + /** Carries only correlation facts needed by the durable command diagnosis record. */ + public static final class SettlementFailure extends BizException { + private final String requestId; + private final String traceId; + + SettlementFailure(ResultCode code, String message, String requestId, String traceId) { + super(code, message); + this.requestId = requestId; + this.traceId = traceId; + } + + public String requestId() { return requestId; } + public String traceId() { return traceId; } + } + private static List settlementTransferFacts( List remainingItems) { return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount()) diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 5033d5924..9fcc58c13 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -564,7 +564,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID', cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照', container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写', - task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成', + task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED', started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', @@ -577,9 +577,52 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( UNIQUE KEY uk_loading_pick_task_booking (container_booking_id), KEY idx_loading_pick_container (container_no, task_status, started_at), CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), - CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED')) ) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; +CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( + id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要', + command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED', + expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本', + remark VARCHAR(500) NULL COMMENT '完成备注', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间', + lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者', + lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间', + failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段', + failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码', + failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID', + failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_loading_completion_operation (operation_id), + KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until), + CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id), + CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令'; + +CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint ( + command_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + owner_sequence INT UNSIGNED NOT NULL, + checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (command_id,tenant_id,inbound_id), + KEY idx_loading_completion_owner (command_id,owner_sequence), + CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id) + REFERENCES fl_loading_completion_command(id), + CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点'; + CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', @@ -1018,8 +1061,10 @@ BEGIN OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; - IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 165d36799..cabdc8daf 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -106,13 +106,13 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete: post: summary: PDA拣货全部处理后人工确认装柜完成 - description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。 + description: 短事务校验任务版本、全部明细和operationId请求摘要,持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实。后台worker按稳定入库单顺序以checkpoint恢复Order、Warehouse、入库状态、计划和事件;请求事务内不调用任何远程服务。同operationId重放立即返回相同PROCESSING或最终结果;不同operationId在处理期间返回A0409。 parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: required: true content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}} responses: - '200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409} + '200': {description: 已可靠受理;taskStatus=COMPLETING且workflowStatus=PROCESSING,或幂等返回最终COMPLETED结果;冲突返回A0409} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations: get: summary: Read immutable task-level claim, assignment and review audit records @@ -139,8 +139,8 @@ components: sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity} recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan} mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history} - taskStatus: {type: string, enum: [LOADING, COMPLETED]} - workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]} + taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]} + workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]} version: {type: integer, minimum: 0} assignment: {type: object, nullable: true} review: {type: object, nullable: true} @@ -181,8 +181,8 @@ components: mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task} warehouseId: {type: integer, format: int64} warehouseName: {type: string, nullable: true} - taskStatus: {type: string, enum: [LOADING]} - workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW} + taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED]} + workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED], description: Server projection aligned with task detail; completion acceptance is returned immediately as PROCESSING} version: {type: integer, minimum: 0} assignmentVersion: {type: integer, minimum: 0} itemCount: {type: integer, minimum: 1} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java new file mode 100644 index 000000000..8ef0f7aa8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingCompletionSqlContractTest { + @Test + void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception { + assertContract(Files.readString(Path.of("src", "main", "resources", "db", + "phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8)); + assertContract(Files.readString(Path.of("..", "deploy", "sql", + "头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8)); + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("fl_loading_completion_command") + .contains("fl_loading_completion_checkpoint") + .contains("uk_loading_completion_operation") + .contains("idx_loading_completion_dispatch") + .contains("PROCESSING","RETRYABLE","FAILED","COMPLETED") + .contains("COMPLETING","COMPLETION_FAILED") + .contains("READY","SETTLED","INBOUND_COMPLETED") + .contains("lease_owner","lease_until","next_retry_at") + .contains("failure_stage","failure_code","failure_request_id","failure_trace_id"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java new file mode 100644 index 000000000..230dd7c41 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingCompletionWorker.class) +class LoadingCompletionWorkerTest { + @MockBean private LoadingCompletionCommandRepository commands; + @MockBean private LoadingPickRepository picks; + @MockBean private LoadingPickService loadingPicks; + @jakarta.annotation.Resource private LoadingCompletionWorker worker; + + @Test + void transientFailureIsReleasedForBackoffRetry() { + LoadingCompletionCommandEntity command = command(1); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT", + new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null))) + .thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class), + eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)); + } + + @Test + void fifthFailureBecomesTerminalAndMarksTaskFailed() { + LoadingCompletionCommandEntity command = command(4); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE", + new BizException(ResultCode.CONFLICT, "plan conflict"))) + .when(loadingPicks).processCompletion(eq(command), any(String.class)); + + when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true); + + worker.dispatch(); + + verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class), + eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null)); + verify(picks).failCompletion(20L); + } + + private static LoadingCompletionCommandEntity command(int attemptCount) { + LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity(); + command.setId(900L); + command.setPickTaskId(20L); + command.setOperationId("complete-1"); + command.setAttemptCount(attemptCount); + return command; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index a1189044e..0263816f7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -27,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -35,6 +36,7 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; import org.mockito.InOrder; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -45,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; @@ -54,6 +57,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.clearInvocations; import static org.assertj.core.api.Assertions.assertThat; @SpringJUnitConfig(LoadingPickService.class) @@ -61,6 +65,8 @@ class LoadingPickServiceTest { @MockBean private LoadingPickRepository repository; @MockBean + private LoadingCompletionCommandRepository completionCommands; + @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private OceanBookingRepository oceanBookingRepository; @@ -89,6 +95,9 @@ class LoadingPickServiceTest { booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.activeBookingByTask(20L)).thenReturn(booking); + when(completionCommands.insert(any())).thenReturn(1); + when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); + when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); } @Test @@ -489,6 +498,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() { LoadingPickTaskEntity task = task(); LoadingPickItemEntity item = item(); @@ -539,6 +549,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void conflictingTruckAppointmentsDoNotBlockPdaCompletion() { LoadingPickTaskEntity task = task(); LoadingPickItemEntity item = item(); @@ -569,6 +580,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void doesNotPublishCompletedEventWhenInboundFinalizationFails() { LoadingPickTaskEntity task = task(); when(repository.taskForUpdate(20L)).thenReturn(task); @@ -593,6 +605,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void translatesUnexpectedSettlementFailureIntoActionableStageMessage() { LoadingPickTaskEntity task = task(); when(repository.taskForUpdate(20L)).thenReturn(task); @@ -615,6 +628,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() { LoadingPickTaskEntity task = task(); task.setVersion(1); @@ -643,6 +657,7 @@ class LoadingPickServiceTest { } @Test + @org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract") void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() { LoadingPickTaskEntity task = task(); task.setVersion(1); @@ -670,6 +685,141 @@ class LoadingPickServiceTest { verify(loadingListService).publishCompletedEvent(10L); } + @Test + void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() { + LoadingPickTaskEntity loading = task(); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + completing.setVersion(1); + AtomicReference accepted = new AtomicReference<>(); + when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get()); + doAnswer(invocation -> { + accepted.set(invocation.getArgument(0)); + return 1; + }).when(completionCommands).insert(any()); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.task(20L)).thenReturn(completing); + when(repository.items(20L)).thenReturn(List.of(item())); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + + Task first = service.complete(20L, + new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"); + verify(repository).taskForUpdate(20L); + clearInvocations(repository); + Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () -> + service.complete(20L, + new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator")); + + assertThat(first.taskStatus()).isEqualTo("COMPLETING"); + assertThat(first.workflowStatus()).isEqualTo("PROCESSING"); + assertThat(replay.taskStatus()).isEqualTo("COMPLETING"); + verify(repository, never()).taskForUpdate(20L); + verify(completionCommands, times(1)).insert(any()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void differentOperationIdIsRejectedImmediatelyWithConflict() { + // different operationId is a canonical A0409 CONFLICT while the durable command is active. + LoadingPickTaskEntity loading = task(); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + active.setOperationId("already-processing"); + when(completionCommands.active(20L)).thenReturn(active); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令"); + + verify(completionCommands, never()).insert(any()); + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() { + LoadingPickTaskEntity failed = task(); + failed.setTaskStatus("COMPLETION_FAILED"); + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + var command = completionCommand(902L, "resume-same-operation"); + command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070"); + command.setCommandStatus("FAILED"); + command.setExpectedTaskVersion(0); + when(completionCommands.operation("resume-same-operation")).thenReturn(command); + when(completionCommands.resume(902L)).thenReturn(true); + when(repository.taskForUpdate(20L)).thenReturn(failed); + when(repository.resumeCompletion(20L)).thenReturn(1); + when(repository.task(20L)).thenReturn(completing); + when(repository.items(20L)).thenReturn(List.of(item())); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L))); + + Task result = service.complete(20L, + new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator"); + + assertThat(result.workflowStatus()).isEqualTo("PROCESSING"); + verify(completionCommands).resume(902L); + verify(repository).resumeCompletion(20L); + verify(completionCommands, never()).insert(any()); + } + + @Test + void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + List owners = java.util.stream.IntStream.range(0, 15) + .mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList(); + when(repository.inboundOwners(20L)).thenReturn(owners); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.review(20L)).thenReturn(null); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(900L, "worker-1")).thenReturn(true); + var command = completionCommand(900L, "async-15"); + + service.processCompletion(command, "worker-1"); + + ArgumentCaptor inboundIds = ArgumentCaptor.forClass(Long.class); + verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator")); + assertThat(inboundIds.getAllValues()).containsExactly( + 1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L, + 1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).completeAccepted(20L, 9L, "operator"); + verify(completionCommands).complete(900L, "worker-1"); + } + + @Test + void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L), + new LoadingPickItemMapper.InboundOwner(101L, 60L))); + when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); + when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED"); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); + when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); + when(completionCommands.complete(901L, "worker-2")).thenReturn(true); + + service.processCompletion(completionCommand(901L, "async-resume"), "worker-2"); + + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(), + anyLong(), anyString(), anyString(), anyString()); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", + "PDA-async-resume-60", "checked"); + verify(loadingListService).publishCompletedEvent(10L); + verify(repository).insert(org.mockito.ArgumentMatchers.argThat( + operation -> "COMPLETE_LOADING".equals(operation.getActionType()))); + } + @Test void atomicallySubmitsCompleteBatchAndReplaysSameResult() { LoadingPickTaskEntity task = task(); @@ -871,6 +1021,21 @@ class LoadingPickServiceTest { when(repository.items(20L, 101L)).thenReturn(List.of(item)); } + private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand( + long id, String operationId) { + var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity(); + command.setId(id); + command.setPickTaskId(20L); + command.setOperationId(operationId); + command.setRequestHash("hash"); + command.setCommandStatus("PROCESSING"); + command.setOperatorId(9L); + command.setOperatorName("operator"); + command.setRemark("checked"); + command.setAttemptCount(1); + return command; + } + private static LoadingPickTaskEntity task() { LoadingPickTaskEntity task = new LoadingPickTaskEntity(); task.setId(20L); diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs new file mode 100644 index 000000000..620f1dcd0 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') + +const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java') +const completionCommand = read('../../firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java') +const completionWorker = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java') +const loadingPickTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java') +const settlementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java') +const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql') +const finalizeHttp = read('phase3-loading-finalize.mjs') +const pdaPage = read('../../../apps/pda-uniapp/pages/picking/picking.vue') +const pdaCompletionState = read('../../../apps/pda-uniapp/utils/loading-completion-state.js') +const pdaCompletionTests = read('../../../apps/pda-uniapp/tests/loading-completion-state.test.mjs') + +const RECOVERY_TASK_ID = '2092817774453780481' + +function retryDecision({ operationStatus, sameRequest, finalTaskStatus }) { + if (!sameRequest) return 'CONFLICT' + if (operationStatus === 'PROCESSING') return 'PROCESSING' + if (operationStatus === 'COMPLETED') return 'REPLAY' + if (finalTaskStatus === 'COMPLETED') return 'RECONCILE_COMPLETE' + return 'RESUME' +} + +test('offline orchestration model distinguishes processing replay recovery and conflict', () => { + assert.equal(retryDecision({ operationStatus: 'PROCESSING', sameRequest: true }), 'PROCESSING') + assert.equal(retryDecision({ operationStatus: 'COMPLETED', sameRequest: true }), 'REPLAY') + assert.equal(retryDecision({ operationStatus: 'FAILED', sameRequest: true, finalTaskStatus: 'LOADING' }), 'RESUME') + assert.equal(retryDecision({ operationStatus: 'FAILED', sameRequest: false }), 'CONFLICT') +}) + +test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => { + const start = loadingPickService.indexOf('public Task complete(') + const end = loadingPickService.indexOf('/** Called only by the completion worker', start) + const block = loadingPickService.slice(start, end) + assert.match(block, /LoadingCompletionCommandEntity/) + assert.match(block, /acceptCompletion/) + assert.match(block, /return view\(requiredTask\(taskId\), null\)/) + assert.doesNotMatch(block, /settlementService|completeAfterPdaSettlement|publishCompletedEvent/) + assert.match(loadingPickService, /"COMPLETING"\.equals\(task\.getTaskStatus\(\)\)[\s\S]{0,80}"PROCESSING"/) +}) + +test('complete does not hold fl_loading_pick_task FOR UPDATE across remote settlement calls', () => { + const start = loadingPickService.indexOf('public void processCompletion(') + const end = loadingPickService.indexOf('private static void requireSameCompletionCommand', start) + const block = loadingPickService.slice(start, end) + assert.notEqual(start, -1) + assert.match(block, /settlementService\.settleOwner/) + assert.doesNotMatch(block, /requiredLoadingTaskForUpdate|requiredTaskForUpdate/) + assert.match(loadingPickService, /completionCommands\.insert/) +}) + +test('completion operation has durable PROCESSING and terminal response state', () => { + assert.match(completionCommand, /commandStatus/) + assert.match(completionCommand, /attemptCount/) + assert.match(completionCommand, /nextRetryAt/) + assert.match(completionCommand, /completedAt/) + assert.match(completionCommand, /failureStage/) + const commandTable = unifiedSql.slice(unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command'), + unifiedSql.indexOf(';', unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command')) + 1) + assert.match(commandTable, /command_status/i) + assert.match(commandTable, /next_retry_at/i) + assert.match(commandTable, /completed_at/i) + assert.match(unifiedSql, /CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint/) +}) + +test('durable worker processes inbound settlements incrementally with retry backoff', () => { + assert.match(completionWorker, /@Scheduled/) + assert.match(completionWorker, /commands\.due/) + assert.match(completionWorker, /commands\.claim/) + assert.match(completionWorker, /15L <<|nextRetryAt|retryAt/) + assert.match(completionWorker, /"FAILED"\s*:\s*"RETRYABLE"/) + assert.match(loadingPickService, /checkpointAtLeast[\s\S]{0,1800}SETTLED/) + assert.match(loadingPickTests, /(?:transient|瞬时)[\s\S]{0,1500}(?:backoff|retry|退避)/i) + assert.match(loadingPickTests, /FAILED[\s\S]{0,1500}(?:same operationId|同operationId|resume|恢复)/i) +}) + +test('same operationId concurrency returns PROCESSING promptly instead of waiting for the task row lock', () => { + assert.match(loadingPickTests, /sameOperationId[\s\S]{0,1600}PROCESSING/i) + assert.match(loadingPickTests, /(?:assertTimeout|timeout|elapsed|millis|seconds)[\s\S]{0,500}(?:CannotAcquireLock|FOR UPDATE|taskForUpdate)/i) + assert.match(loadingPickTests, /never\(\)[\s\S]{0,180}requiredLoadingTaskForUpdate|never\(\)[\s\S]{0,180}taskForUpdate/i) +}) + +test('a different operationId is rejected while the completion command is processing', () => { + assert.match(loadingPickTests, /(?:different operationId|不同operationId|different key|不同键)[\s\S]{0,1200}(?:A0409|CONFLICT)/i) +}) + +test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => { + assert.match(settlementTests, /15|fifteen/i) + assert.match(settlementTests, /(?:partial success|部分成功|local rollback|本地回滚)/i) + assert.match(settlementTests, /(?:settlementTarget|COMPLETED|replaySettlement)/) + assert.match(loadingPickTests, /(?:resume|recover|reconcile|恢复|核平)[\s\S]{0,2000}COMPLETE_LOADING/i) +}) + +test('recovery fixture preserves the observed fifteen-settlement partial success', () => { + assert.equal(RECOVERY_TASK_ID, '2092817774453780481') + assert.match(finalizeHttp, new RegExp(RECOVERY_TASK_ID)) + assert.match(finalizeHttp, /15[\s\S]{0,600}(?:Warehouse|settlement)/i) + assert.match(finalizeHttp, /(?:LOADING|v1)[\s\S]{0,600}(?:恢复|recovery|resume)/i) +}) + +test('final acceptance reconciles task allocation reservation occupancy and one completion audit', () => { + for (const fact of ['task', 'allocation', 'reservation', 'occupancy']) { + assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing final ${fact} reconciliation`) + } + assert.match(finalizeHttp, /COMPLETE_LOADING/) + assert.match(finalizeHttp, /(?:length === 1|恰有一次|只写一条)/) + assert.match(finalizeHttp, /(?:PROCESSING|处理中)/) + assert.match(finalizeHttp, /(?:elapsed|latency|耗时|毫秒|ms)/i) +}) + +test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => { + assert.match(pdaCompletionState, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/) + assert.match(pdaCompletionTests, /accepted completion is processing/) + assert.match(pdaPage, /loading-completion-state/) + assert.match(pdaPage, /(?:PROCESSING|COMPLETING)[\s\S]{0,1200}navigateBackOr/) + assert.doesNotMatch(pdaPage, /completeContainerPickingTask[\s\S]{0,700}showToast\('已确认装柜完成'/, + 'accepted PROCESSING response must not show a completed success toast') + assert.match(pdaPage, /shouldReconcileCompletion/) +}) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index 72e561605..9e09034f1 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -27,6 +27,9 @@ class InitialWarehouseResourceSeedSqlContractTest { } private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) { + int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201"); + int locationSeedEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_yiwu_location_seed", locationSeedStart); + String locationSeed = sql.substring(locationSeedStart, locationSeedEnd); assertThat(sql) .contains("0," + yiwuId + ",'TP-001','木托盘'") .contains("0," + dongguanId + ",'TP-002','塑料托盘'") @@ -50,11 +53,12 @@ class InitialWarehouseResourceSeedSqlContractTest { .contains("(" + idPrefix + "204,0," + dongguanId + ",'A','A区','A0'") .contains("shared_overflow") .contains("'AVAILABLE'") - .doesNotContain("status='AVAILABLE',\n remark=VALUES(remark)") - .doesNotContain("status = 'AVAILABLE',\n remark = VALUES(remark)") - .doesNotContain("remark=VALUES(remark),\n deleted=0") - .doesNotContain("remark = VALUES(remark),\n deleted = 0") .doesNotContain("INSERT INTO wh_location_reservation") .doesNotContain("INSERT INTO wh_location_occupancy"); + assertThat(locationSeed) + .doesNotContain("status='AVAILABLE'") + .doesNotContain("status = 'AVAILABLE'") + .doesNotContain("deleted=0") + .doesNotContain("deleted = 0"); } } From 0fb51211aa04d74d2195bcaabfe648b5579a50c4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 15:02:37 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 45 ++++++++++++------- .../PdaConvergenceOpenApiContractTest.java | 3 +- .../LoadingPickPdaTaskSqlContractTest.java | 4 +- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b1fb48862..38bf980aa 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5365,7 +5365,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID', cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照', container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写', - task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成', + task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED', started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', @@ -5378,7 +5378,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( UNIQUE KEY uk_loading_pick_task_booking (container_booking_id), KEY idx_loading_pick_container (container_no, task_status, started_at), CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), - CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED')) ) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( @@ -13768,7 +13768,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'loading_pl CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_at', '开始时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by', '开始装柜人'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by_name', '开始装柜人名称'); -CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成'); +CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_at', '确认时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_by', '确认操作人ID'); @@ -16514,24 +16514,39 @@ ALTER TABLE fl_loading_pick_task ADD CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED')); CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( - id BIGINT UNSIGNED NOT NULL,pick_task_id BIGINT UNSIGNED NOT NULL,operation_id VARCHAR(128) NOT NULL, - request_hash CHAR(64) NOT NULL,command_status VARCHAR(16) NOT NULL,expected_task_version INT UNSIGNED NOT NULL, - remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL, - attempt_count INT UNSIGNED NOT NULL DEFAULT 0,next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL,lease_until DATETIME(3) NULL,failure_stage VARCHAR(40) NULL, - failure_code VARCHAR(32) NULL,failure_request_id VARCHAR(64) NULL,failure_trace_id VARCHAR(64) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL, + id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '关联PDA任务ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要', + command_status VARCHAR(16) NOT NULL COMMENT '命令状态', + expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本', + remark VARCHAR(500) NULL COMMENT '装柜完成备注', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '后台认领次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许处理时间', + lease_owner VARCHAR(128) NULL COMMENT '后台租约持有者', + lease_until DATETIME(3) NULL COMMENT '后台租约截止时间', + failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段', + failure_code VARCHAR(32) NULL COMMENT '业务或下游错误码', + failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID', + failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令更新时间', + completed_at DATETIME(3) NULL COMMENT '命令完成时间', PRIMARY KEY(id),UNIQUE KEY uk_loading_completion_operation(operation_id), KEY idx_loading_completion_dispatch(command_status,next_retry_at,lease_until), CONSTRAINT fk_loading_completion_task FOREIGN KEY(pick_task_id) REFERENCES fl_loading_pick_task(id), CONSTRAINT chk_loading_completion_status CHECK(command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED')) ) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令'; CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint ( - command_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL, - owner_sequence INT UNSIGNED NOT NULL,checkpoint_stage VARCHAR(24) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入库单客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序', + checkpoint_stage VARCHAR(24) NOT NULL COMMENT '入库单处理检查点', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间', PRIMARY KEY(command_id,tenant_id,inbound_id),KEY idx_loading_completion_owner(command_id,owner_sequence), CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY(command_id) REFERENCES fl_loading_completion_command(id), CONSTRAINT chk_loading_completion_checkpoint CHECK(checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED')) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 73c577614..8c12b1d03 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest { .contains("sourceType", "recordStatus", "mergedIntoContainerBookingId") .contains("Task detail is served only from the ACTIVE container booking") .contains("Enforced by the server-side pending-task query") - .contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW") + .contains("completion acceptance is returned immediately as PROCESSING") + .contains("taskStatus=COMPLETING且workflowStatus=PROCESSING") .contains("CompleteLoadingRequest"); String taskLookup = yaml.substring( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 91576c639..591eeefcc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest { .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("task.task_status = 'LOADING'") + .contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')") .contains("booking.record_status = 'ACTIVE'") .contains("booking.source_type AS sourceType") .contains("booking.record_status AS recordStatus") .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") .contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'") + .contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'") + .contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'") .contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'") .contains("pick_item.item_status IN ('PENDING', 'PICKING')") .contains("THEN 'AWAITING_REVIEW'") From adabec13d3c4cc2d41cdb39787122793e0cc8d22 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 15:21:28 +0800 Subject: [PATCH 06/19] fix(print): include Spring DAO exceptions at runtime --- common-web/pom.xml | 4 ++++ .../print/acceptance/PrintStatelessArchitectureTest.java | 3 +++ 2 files changed, 7 insertions(+) diff --git a/common-web/pom.xml b/common-web/pom.xml index 3a5a14627..ee8c7876b 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -23,6 +23,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework + spring-tx + org.springframework.security spring-security-core diff --git a/print/src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java b/print/src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java index eb0069624..9dedbf27f 100644 --- a/print/src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java +++ b/print/src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java @@ -44,6 +44,9 @@ class PrintStatelessArchitectureTest { @Test void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() { ClassLoader loader = getClass().getClassLoader(); + assertThat(isPresent("org.springframework.dao.CannotAcquireLockException", loader)) + .as("common-web exception handling requires Spring DAO exceptions at runtime") + .isTrue(); assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader)) .as("Print must exclude the JDBC starter inherited through common-core") .isFalse(); From 0f2e201c38c9dedf41720d9464db1f01de1e8b0b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 15:56:14 +0800 Subject: [PATCH 07/19] fix(lastleg): unblock inbound plan handoff --- .../event/FirstLegLoadingPlanCompletedV1.java | 26 +++---- .../event/FirstLegLoadingPlanCompletedV2.java | 27 +++---- .../openapi/cross-region-events-v1.yaml | 35 +++++++++ .../FirstLegLoadingPlanCompletedV1Test.java | 7 +- .../FirstLegLoadingPlanCompletedV2Test.java | 36 ++++++++- deploy/sql/海外区域统一增量-MySQL8.sql | 6 ++ .../baiyue/firstleg/dto/LoadingPickDtos.java | 8 +- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../LoadingCompletionCommandMapper.java | 3 + .../LoadingCompletionCommandRepository.java | 1 + .../LoadingCompletedOutboxService.java | 23 +++++- .../firstleg/service/LoadingPickService.java | 8 +- .../openapi/phase3-pda-convergence-v1.yaml | 17 +++++ .../SmartLoadingSqlContractTest.java | 2 +- .../LoadingCompletedOutboxServiceTest.java | 37 +++++++++- .../TaillegRealProducerAcceptanceTest.java | 8 +- .../controller/InboundPlanController.java | 10 ++- .../dto/ConvertInboundPlanRequest.java | 3 +- .../service/InboundPlanService.java | 3 + .../LastlegOrderNoDailySequenceMapper.java | 19 +++++ .../service/LastlegOrderNoGenerator.java | 38 ++++------ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 6 ++ .../openapi/overseas-business-v1.yaml | 16 ++++ .../controller/InboundPlanControllerTest.java | 30 ++++++++ .../service/InboundPlanServiceTest.java | 20 +++++ .../service/LastlegOrderNoGeneratorTest.java | 73 +++++++++++++------ .../service/LoadingCompletedConsumerTest.java | 12 +-- 28 files changed, 378 insertions(+), 103 deletions(-) create mode 100644 common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 7d8dd65bd..8c9a4578d 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,10 +16,16 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets, + List items, String customerId, String customerName, String orderType, List pallets, String destinationType) { public Order { items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } @@ -29,23 +35,13 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String customerName, + String orderType, List pallets) { this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); - } - public Order(String sourceInboundId, String rootInboundId, - String sourceOrderId, String sourceOrderNo, String rootOrderId, - String inboundCode, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount, Integer productQuantity, Integer palletCount, - BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { - this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, - inboundCode, destinationWarehouseId, destinationWarehouseCode, - destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + orderType, pallets, null); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 786ebae4c..cf908f2f6 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -15,11 +15,17 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets, + List items, String customerId, String customerName, String orderType, List pallets, /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ String destinationType) { public Order { items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } @@ -30,24 +36,13 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String customerName, + String orderType, List pallets) { this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); - } - - public Order(String sourceInboundId, String rootInboundId, - String sourceOrderId, String sourceOrderNo, String rootOrderId, - String inboundCode, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount, Integer productQuantity, Integer palletCount, - BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { - this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, - inboundCode, destinationWarehouseId, destinationWarehouseCode, - destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + orderType, pallets, null); } } diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml new file mode 100644 index 000000000..aadee97be --- /dev/null +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Baiyue Cross-region Event Contracts + version: 1.0.0 +paths: {} +components: + schemas: + FirstLegLoadingPlanCompletedV2: + type: object + required: [loadingPlanId, containerBookingId, orders] + properties: + loadingPlanId: {type: string} + containerBookingId: {type: string} + containerNo: {type: string} + cabinetNo: {type: string} + warehouseId: {type: string} + warehouseName: {type: string} + orders: + type: array + items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'} + FirstLegLoadingPlanCompletedOrderV2: + type: object + required: [sourceInboundId, customerId, customerName, packageCount, items, orderType, pallets, destinationType] + properties: + sourceInboundId: {type: string} + sourceOrderId: {type: string} + sourceOrderNo: {type: string} + inboundCode: {type: string} + customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg} + customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM} + packageCount: {type: integer, minimum: 1} + items: {type: array, items: {type: object}} + orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} + pallets: {type: array, items: {type: object}} + destinationType: {type: string, enum: [FBA, OVERSEAS]} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 95a6b394b..fe22ac20c 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test { var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", "ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), - new BigDecimal("0.200"), List.of(item)); + new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of()); var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008", "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); String json = mapper.writeValueAsString(payload); @@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test { "SKU-2", "产品2", 1, 5, List.of("BOX-003")); var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", "RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, - BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP", + BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP", List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)), new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, @@ -53,7 +53,8 @@ class FirstLegLoadingPlanCompletedV1Test { String json = """ {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", - "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + "destinationWarehouseId":"3","packageCount":1,"items":[],"customerId":"9001", + "customerName":"客户甲","destinationType":"OVERSEAS"}]} """; FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index d15900021..812c1603e 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.List; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { @Test @@ -14,7 +16,8 @@ class FirstLegLoadingPlanCompletedV2Test { 2, null, null, null, null, null, null, null, null, null, null, null, "客户备注", "内部备注", "仓库备注"); var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", - null, null, null, 1, 2, null, null, null, null, List.of(item)); + null, null, null, 1, 2, null, null, null, null, List.of(item), + "11", "客户甲", "FIRST_LEG", List.of()); var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); String json = new ObjectMapper().writeValueAsString(payload); assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); @@ -29,13 +32,14 @@ class FirstLegLoadingPlanCompletedV2Test { var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku)); var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, null, null, - 2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet)); + 2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet)); var payload = new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); var json = new ObjectMapper().valueToTree(payload); assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲"); assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } @@ -44,7 +48,7 @@ class FirstLegLoadingPlanCompletedV2Test { void carriesFrozenDestinationType() { var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", - 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + 2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA"); var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); @@ -58,7 +62,8 @@ class FirstLegLoadingPlanCompletedV2Test { 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", - null, null, null, 2, 20, null, null, null, null, List.of(item)); + null, null, null, 2, 20, null, null, null, null, List.of(item), + "11", "客户甲", "FIRST_LEG", List.of()); var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); @@ -66,4 +71,27 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2"); assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); } + + @Test + void rejectsMissingImmutableCustomerName() { + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName"); + } + + @Test + void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception { + String yaml; + try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream( + "/openapi/cross-region-events-v1.yaml")) { + assertThat(input).isNotNull(); + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertThat(yaml) + .contains("required: [sourceInboundId, customerId, customerName") + .contains("customerId: {type: string, minLength: 1") + .contains("customerName: {type: string, minLength: 1, maxLength: 128") + .contains("consumers must not query domestic IAM"); + } } diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f7a4db970..a1f20dcae 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -498,6 +498,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; +CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 2016fbc7d..519fa5485 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -125,12 +125,18 @@ public final class LoadingPickDtos { Long operatorId, String operatorName, LocalDateTime createdAt) { } + public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark, + Integer attemptCount, LocalDateTime nextRetryAt, String failureStage, + String failureCode, String requestId, String traceId, + LocalDateTime completedAt) { + } + public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId, Integer inboundVersion, Integer loadingVersion, String cabinetNo, String currentContainerNo, String sourceType, String recordStatus, Long mergedIntoContainerBookingId, String taskStatus, String workflowStatus, Integer version, Assignment assignment, - Review review, List items) { + Review review, CompletionCommand completionCommand, List items) { } /** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 84786775d..d75cc6c33 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -15,6 +15,7 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.customer_id AS booking_customer_id, b.customer_name, i.account_id, i.inbound_id AS source_inbound_id, COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, @@ -79,7 +80,8 @@ public interface LoadingCompletedEventMapper { class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; private Integer aggregateVersion; private String containerNo; private String cabinetNo; - private Long warehouseId; private String warehouseName; private Long accountId; + private Long warehouseId; private String warehouseName; private Long bookingCustomerId; + private String customerName; private Long accountId; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java index 777523447..3fe11e0b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java @@ -33,6 +33,9 @@ public interface LoadingCompletionCommandMapper extends BaseMapper due(int limit) { return mapper.findDue(limit); } public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; } public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index d2c706779..361e2bb67 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -37,7 +37,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -/** 在装柜完成事务内按客户写跨区业务 Outbox。 */ +/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService { public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + validateCustomerSnapshots(rows); Map> cartonMarks = cartonLabelPools(rows); List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, @@ -104,10 +105,28 @@ public class LoadingCompletedOutboxService { first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, destinationType(sorted)); } + private void validateCustomerSnapshots(List rows) { + for (Row row : rows) { + if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) { + throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件"); + } + if (row.getCustomerName() == null || row.getCustomerName().isBlank()) { + throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件"); + } + } + rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> { + Set names = values.stream().map(Row::getCustomerName).map(String::trim) + .collect(java.util.stream.Collectors.toSet()); + if (names.size() != 1) { + throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照"); + } + }); + } + /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index e0eb96dd0..eb919c242 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; +import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; @@ -837,12 +838,17 @@ public class LoadingPickService { .filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null); var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null ? null : repository.inbound(owner.tenantId(), owner.inboundId()); + LoadingCompletionCommandEntity command = completionCommands.latest(task.getId()); + CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(), + command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(), + command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(), + command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt()); return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(), inbound == null ? null : inbound.version(), task.getCabinetNo(), booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(), booking.getMergedIntoContainerBookingId(), task.getTaskStatus(), - workflowStatus, task.getVersion(), assignment, review, items); + workflowStatus, task.getVersion(), assignment, review, completionCommand, items); } private List validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) { diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index cabdc8daf..7df494d3d 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -141,6 +141,7 @@ components: mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history} taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]} workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]} + completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true} version: {type: integer, minimum: 0} assignment: {type: object, nullable: true} review: {type: object, nullable: true} @@ -236,6 +237,22 @@ components: expectedTaskVersion: {type: integer, minimum: 0} operationId: {type: string, maxLength: 128} remark: {type: string, maxLength: 500} + CompletionCommand: + type: object + required: [id, operationId, status, expectedTaskVersion, attemptCount] + properties: + id: {type: integer, format: int64} + operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity} + status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]} + expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version} + remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time, nullable: true} + failureStage: {type: string, nullable: true} + failureCode: {type: string, nullable: true} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} CompleteLoadingRequest: type: object required: [expectedTaskVersion, operationId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index e5c1ddc08..c7cb7aa7b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -308,7 +308,7 @@ class SmartLoadingSqlContractTest { .contains("container_booking_id BIGINT UNSIGNED NOT NULL") .contains("cabinet_no VARCHAR(64) NOT NULL") .contains("container_no VARCHAR(64) NULL") - .contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'") + .contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'") .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") .contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)") .contains("KEY idx_loading_pick_container (container_no, task_status, started_at)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 50ff2452d..4d89c4602 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -59,6 +59,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); + assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -72,6 +73,22 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } + @Test void rejectsMissingTrustedBookingCustomerName() { + Row value = row(11, 100, 101); + value.setCustomerName(" "); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照"); + } + + @Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() { + Row value = row(11, 100, 101); + value.setBookingCustomerId(22L); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致"); + } + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { Row value = row(11, 100, 101); value.setDestinationType("FBA"); @@ -118,6 +135,19 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void immutableCustomerNameParticipatesInPayloadHash() { + Row value = row(11, 100, 101); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + service.publish(90L); + value.setCustomerName("客户甲-更名快照"); + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues().get(0).getPayloadHash()) + .isNotEqualTo(captor.getAllValues().get(1).getPayloadHash()); + } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { Row first = row(11, 100, 101); first.setPackageCount(1); Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); @@ -144,7 +174,9 @@ class LoadingCompletedOutboxServiceTest { .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") - .contains("o.process_status = 'LOADED'"); + .contains("o.process_status = 'LOADED'") + .contains("b.customer_id AS booking_customer_id") + .contains("b.customer_name"); for (String sql : List.of(rowsSql, palletsSql)) { assertThat(sql) .contains("actual_pick.loading_plan_item_id = i.id") @@ -219,7 +251,8 @@ class LoadingCompletedOutboxServiceTest { private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); - r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); + r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account); + r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index e593f1aa2..085ce11eb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -50,12 +50,16 @@ class TaillegRealProducerAcceptanceTest { assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("真实验收客户", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); } private void seedCompletedLoadingPlan() { jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + - "warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)", - BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); + "warehouse_name,customer_id,customer_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,1,1)", + BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", + CUSTOMER, "真实验收客户", "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3f8c3da74..841d24fd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -49,7 +49,7 @@ public class InboundPlanController { + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); + return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); } @GetMapping("/{planNo}") @@ -118,4 +118,12 @@ public class InboundPlanController { } return String.valueOf(user.tenantId()); } + + private String convertScope(AuthenticatedUser user, String requestedCustomer) { + if (user != null && "PLATFORM".equals(user.appCode()) && user.tenantId() != null && user.tenantId() == 0L + && (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer.trim()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } + return scope(user, requestedCustomer); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java index 8d4a6a20b..615215fa4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java @@ -1,9 +1,10 @@ package com.baiyue.lastleg.inboundplan.dto; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public record ConvertInboundPlanRequest( @NotNull Long orderId, - String customerId + @Size(max = 128) String customerId ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index ec121e6d7..5509c1510 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -59,6 +59,9 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } List orders = orderMapper.lockOrder(customerId, selectedOrderId); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java new file mode 100644 index 000000000..7f18718cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; + +@Mapper +public interface LastlegOrderNoDailySequenceMapper { + @Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " + + "VALUES(#{businessDate},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") + int nextValue(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT LAST_INSERT_ID()") + long currentValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 86b89766f..766152347 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,35 +1,29 @@ package com.baiyue.lastleg.service; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Component; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; @Component public class LastlegOrderNoGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd"); + @Resource private LastlegOrderNoDailySequenceMapper sequenceMapper; + public String next() { - return "LMO" + IdWorker.getIdStr(); + return next(LocalDate.now(BUSINESS_ZONE)); } - /** The physical container number is the tail-leg business order grouping boundary. */ - public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) { - throw new IllegalArgumentException("containerNo is required"); - } - String normalized = containerNo.trim(); - if (normalized.matches("[0-9]{1,29}")) { - return "LMO" + normalized; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(normalized.getBytes(StandardCharsets.UTF_8)); - String digits = new BigInteger(1, digest).toString(); - return "LMO" + digits.substring(0, Math.min(29, digits.length())); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); + String next(LocalDate businessDate) { + sequenceMapper.nextValue(businessDate); + long sequence = sequenceMapper.currentValue(); + if (sequence < 1 || sequence > 9999) { + throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate); } + return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 0bf133005..8cdd78592 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -83,7 +83,7 @@ public class LoadingCompletedConsumer { shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", payload.containerNo(), 0, envelope.handoverVersion()); - String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + String orderNo = orderNoGenerator.next(); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); @@ -183,6 +183,7 @@ public class LoadingCompletedConsumer { order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setCustomerName(source.customerName()); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 44d82c7d3..2a5382912 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; +CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', 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 40e76a822..222db0c4b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -156,6 +156,22 @@ paths: - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } + /api/platform/v1/last-leg/inbound-plans/convert: + post: + summary: 平台生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, orderId] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + orderId: { type: string, pattern: '^[1-9][0-9]*$' } + responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index e95ead766..8a72de63b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -20,6 +21,7 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -57,6 +59,34 @@ class InboundPlanControllerTest { verify(appointmentService).update("100", "APT-1", request); } + @Test + void platformConversionRequiresAnExplicitCustomer() { + assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("平台生成入库计划必须指定客户"); + verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void platformConversionPassesTheProvidedLongCustomerId() { + ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L, + "193000000000200003"); + + controller.convert(platform(), request); + + verify(service).convert("193000000000200003", 9223372036854775807L); + } + + @Test + void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200"); + + controller.convert(customer, request); + + verify(service).convert("100", 2L); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index be0a0504c..1229d01ce 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -161,6 +161,26 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void refusesMissingCustomerScopeBeforeLookingUpAnOrder() { + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert(null, 2L)); + + assertEquals(400, error.getStatusCode().value()); + assertEquals("平台生成入库计划必须指定客户", error.getReason()); + verify(orderMapper, never()).lockOrder(any(), any()); + } + + @Test + void wrongCustomerCannotResolveAnOrderOutsideItsScope() { + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of()); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert("200", 2L)); + + assertEquals(404, error.getStatusCode().value()); + verify(orderMapper).lockOrder("200", 2L); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + @Test void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 410b21505..8c729bf5a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -1,31 +1,56 @@ package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; +import org.apache.ibatis.annotations.Insert; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Set; -import java.util.concurrent.*; -import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) class LastlegOrderNoGeneratorTest { - @Test void actualContainerNumberIsStableAndShared() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); - assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); - assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); - assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); - assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); + @Mock LastlegOrderNoDailySequenceMapper sequenceMapper; + private LastlegOrderNoGenerator generator; + + @BeforeEach + void setUp() { + generator = new LastlegOrderNoGenerator(); + ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper); } - @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { - LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); - String first=beforeRestart.next(); - LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator(); - assertNotEquals(first,afterRestart.next()); - ExecutorService pool=Executors.newFixedThreadPool(8); - try { - var tasks=IntStream.range(0,1000).mapToObj(i->(Callable)afterRestart::next).toList(); - Set ids=pool.invokeAll(tasks).stream().map(future->{ - try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); } - }).collect(java.util.stream.Collectors.toSet()); - assertEquals(1000,ids.size()); - } finally { pool.shutdownNow(); } + @Test + void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() { + LocalDate date = LocalDate.of(2026, 8, 27); + when(sequenceMapper.currentValue()).thenReturn(1L, 2L); + + assertEquals("LMO-260827-0001", generator.next(date)); + assertEquals("LMO-260827-0002", generator.next(date)); + verify(sequenceMapper, times(2)).nextValue(date); + } + + @Test + void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception { + Insert insert = LastlegOrderNoDailySequenceMapper.class + .getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class); + + assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE")); + assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); + } + + @Test + void rejectsDailySequenceBeyondFourDigits() { + when(sequenceMapper.currentValue()).thenReturn(10_000L); + assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27))); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index bd54604ef..9fe37253e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -2,9 +2,9 @@ package com.baiyue.lastleg.service; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @@ -17,14 +17,14 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); - var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); assertEquals(1,service.consume(event)); @@ -112,6 +112,6 @@ class LoadingCompletedConsumerTest { assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); } - private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } } From 1db3e485065cf089381acca8b55724d962e3ca4a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:17:11 +0800 Subject: [PATCH 08/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event-bridge-pending-recovery.mjs | 49 ++++++++ .../tailleg-owner-order-number-contract.mjs | 47 ++++++++ ...nt-bridge-dns-deployment-contract.test.mjs | 108 +++++++++++++++++ ...illeg-owner-order-number-contract.test.mjs | 110 ++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs create mode 100644 scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs create mode 100644 scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs diff --git a/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs new file mode 100644 index 000000000..d7257fb53 --- /dev/null +++ b/scripts/admin-test-env-modules/event-bridge-pending-recovery.mjs @@ -0,0 +1,49 @@ +export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({ + customerCode: 'C10000000', + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + initialOutboxStatus: 'PENDING', + initialRetryCount: 62, + failedTarget: '172.31.198.8:9313', + currentOutboxStatus: 'PENDING', + currentRetryCount: 81, + currentFailureCode: 'A0401', + currentFailureMessage: '内部服务令牌无效', + currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', + currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', + dockerDnsProven: true, +}) + +export const TEST_EVENT_BRIDGE_URLS = Object.freeze({ + LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', + FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', +}) + +export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([ + 'firstleg', + 'event-bridge-cn', + 'event-bridge-overseas', + 'lastleg-service', +]) + +export function validTestEventBridgeEnvironment(values = {}) { + return Object.entries(TEST_EVENT_BRIDGE_URLS) + .every(([key, expected]) => values[key] === expected) + && Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value))) +} + +// Callers must provide only a non-secret fingerprint/revision marker, never the token value. +export function validInternalTokenCohort(fingerprints = {}) { + const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service]) + return values.every((value) => typeof value === 'string' && value.length > 0) + && new Set(values).size === 1 +} + +export function recoveredEventBridgeFacts(facts = {}) { + return facts.firstlegOutboxStatus === 'SENT' + && Number(facts.overseasBridgeEventCount) === 1 + && Number(facts.lastlegInboxEventCount) === 1 + && Number(facts.lastlegOrderCount) === 1 + && Number(facts.cnBridgeDuplicateCount) === 0 + && Number(facts.manualReplayCount) === 0 + && Number(facts.sqlMutationCount) === 0 +} diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs new file mode 100644 index 000000000..6397cefe9 --- /dev/null +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -0,0 +1,47 @@ +export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', + payloadMutable: false, + reusableForAcceptance: false, +}) + +export const TAILLEG_ORDER_NUMBER = Object.freeze({ + pattern: /^LMO-\d{6}-\d{4}$/, + timeZone: 'Asia/Shanghai', + sharedBy: 'containerNo', +}) + +export function validOwnerSnapshots(orders, expectedCount = orders?.length) { + if (!Array.isArray(orders) || orders.length !== expectedCount || orders.length === 0) return false + const owners = new Set() + for (const order of orders) { + const customerId = String(order?.customerId ?? '').trim() + const customerName = String(order?.customerName ?? '').trim() + if (!customerId || !customerName) return false + owners.add(`${customerId}\u0000${customerName}`) + } + return owners.size === 1 +} + +export function validContainerOrderGroup(rows) { + if (!Array.isArray(rows) || rows.length === 0) return false + const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim())) + const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim())) + const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim()) + return containerNos.size === 1 && !containerNos.has('') + && orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0]) + && sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length +} + +export function validEventReplay(before, after) { + return Number(before?.orderCount) === Number(after?.orderCount) + && String(before?.orderNo ?? '') === String(after?.orderNo ?? '') + && Number(before?.detailCount) === Number(after?.detailCount) + && Number(before?.historyCount) === Number(after?.historyCount) +} + +export function adminPreservesOrderNumber(apiValue, displayedValue, copiedValue) { + return TAILLEG_ORDER_NUMBER.pattern.test(String(apiValue ?? '')) + && displayedValue === apiValue + && copiedValue === apiValue +} diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs new file mode 100644 index 000000000..2a1234cb5 --- /dev/null +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + EVENT_BRIDGE_RECOVERY_FIXTURE, + INTERNAL_TOKEN_COHORT_SERVICES, + recoveredEventBridgeFacts, + TEST_EVENT_BRIDGE_URLS, + validInternalTokenCohort, + validTestEventBridgeEnvironment, +} from './event-bridge-pending-recovery.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const cnCompose = read('../../deploy/docker-compose.yml') +const overseasCompose = read('../../deploy/docker-compose.overseas.yml') +const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh') +const deployServices = read('../../deploy/scripts/deploy-services.sh') +const crossRegionHttp = read('phase4-cross-region-events.mjs') + +test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => { + assert.deepEqual(TEST_EVENT_BRIDGE_URLS, { + LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', + FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', + }) + assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true) + assert.equal(validTestEventBridgeEnvironment({ + ...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313', + }), false) +}) + +test('Compose wires both producers to the stable service aliases used by test env', () => { + assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/) + assert.match(overseasCompose, + /FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/) + assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/) + for (const source of [cnCompose, overseasCompose]) { + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/) + assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/) + } +}) + +test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => { + assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [ + 'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service', + ]) + assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(overseasCompose, + /x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) + assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/) + assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/) + assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/) + assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/) + for (const source of [deployBuilt, deployServices]) { + assert.match(source, /--env-file\s+"\$ENV_FILE"/) + } + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'revision-20260827', + 'lastleg-service': 'revision-20260827', + }), true) + assert.equal(validInternalTokenCohort({ + firstleg: 'revision-20260827', + 'event-bridge-cn': 'revision-20260827', + 'event-bridge-overseas': 'stale-revision', + 'lastleg-service': 'revision-20260827', + }), false) +}) + +test('incident fixture records DNS recovery followed by retry81 token rejection', () => { + assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, { + customerCode: 'C10000000', + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + initialOutboxStatus: 'PENDING', + initialRetryCount: 62, + failedTarget: '172.31.198.8:9313', + currentOutboxStatus: 'PENDING', + currentRetryCount: 81, + currentFailureCode: 'A0401', + currentFailureMessage: '内部服务令牌无效', + currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', + currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', + dockerDnsProven: true, + }) +}) + +test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => { + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), true) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0, + manualReplayCount: 0, sqlMutationCount: 0, + }), false) + assert.equal(recoveredEventBridgeFacts({ + firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, + lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, + manualReplayCount: 1, sqlMutationCount: 0, + }), false) + assert.match(crossRegionHttp, /const delivered = await poll/) + assert.match(crossRegionHttp, /deliveredRows\.length > 0/) + assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/) +}) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs new file mode 100644 index 000000000..a71ca5a63 --- /dev/null +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + adminPreservesOrderNumber, + IMMUTABLE_TAILLEG_EVENT_FIXTURE, + TAILLEG_ORDER_NUMBER, + validContainerOrderGroup, + validEventReplay, + validOwnerSnapshots, +} from './tailleg-owner-order-number-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') +const producer = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java') +const producerRows = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java') +const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java') +const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java') +const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java') +const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java') +const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java') +const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java') +const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +const adminPresentation = read('../../../apps/admin-web/src/modules/overseas-warehouse/lastMilePresentation.ts') +const adminList = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') +const adminDetail = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue') + +test('new 15-order event requires one non-empty trusted owner snapshot', () => { + const orders = Array.from({ length: 15 }, (_, index) => ({ + sourceInboundId: `inbound-${index + 1}`, customerId: 'customer-1', customerName: '验收货主', + })) + assert.equal(validOwnerSnapshots(orders, 15), true) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '' } : order), 15), false) + assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7 + ? { ...order, customerName: '另一货主' } : order), 15), false) +}) + +test('the processed historical event is immutable and cannot be reused for acceptance', () => { + assert.deepEqual(IMMUTABLE_TAILLEG_EVENT_FIXTURE, { + eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', + status: 'PROCESSED', payloadMutable: false, reusableForAcceptance: false, + }) +}) + +test('V2 producer freezes customerId and customerName and rejects a blank name', () => { + assert.match(eventV2, /List items,\s*String customerId,\s*String customerName,\s*String orderType/) + assert.match(producerRows, /b\.customer_name/) + assert.match(producer, /first\.getCustomerName\(\)/) + assert.match(producer, /(?:customerName|getCustomerName)[\s\S]{0,300}(?:isBlank|hasText)/) +}) + +test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => { + assert.match(consumer, /source\.customerName\(\)/) + assert.match(consumer, /customerName[\s\S]{0,240}(?:isBlank|hasText)/) + assert.match(consumer, /order\.setCustomerName\(customerName\)/) + assert.match(orderEntity, /private String customerName;/) + assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/) +}) + +test('Lastleg query DTO and Admin list/detail use the frozen owner name', () => { + assert.match(orderService, /o\.getCustomerName\(\)/) + assert.match(adminApi, /customerName\?: string \| null/) + assert.match(adminPresentation, /owner:\s*text\(dto\.customerName\)/) + assert.match(adminDetail, /label:\s*'货主',\s*value:\s*text\(order\?\.customerName\)/) +}) + +test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true) + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) + assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') + assert.match(generator, /Asia\/Shanghai/) + assert.match(generator, /yyMMdd/) + assert.match(generator, /LMO-/) + assert.match(generator, /%04d|padStart\(4|String\.format/) + assert.match(generatorTests, /(?:concurrent|并发)/i) + assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) + assert.match(generatorTests, /\^LMO-\\d\{6\}-\\d\{4\}\$/) +}) + +test('one container with 15 inbound rows shares one new LMO number', () => { + const rows = Array.from({ length: 15 }, (_, index) => ({ + containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`, + })) + assert.equal(validContainerOrderGroup(rows), true) + assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 + ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) + assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.[^(]+\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) + assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource\)/) +}) + +test('same event replay adds nothing and preserves the allocated number', () => { + const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } + assert.equal(validEventReplay(before, { ...before }), true) + assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) + assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) + assert.match(consumerTests, /(?:replay|duplicate|重复)[\s\S]{0,900}(?:orderNo|OrderNo)/i) +}) + +test('Admin displays and copies the exact queried number without reformatting', () => { + const value = 'LMO-260827-0042' + assert.equal(adminPreservesOrderNumber(value, value, value), true) + assert.equal(adminPreservesOrderNumber(value, value.replaceAll('-', ''), value), false) + assert.match(adminPresentation, /orderNo:\s*text\(dto\.orderNo\)/) + assert.match(adminList, /prop:\s*'orderNo'/) + assert.match(adminDetail, /reference-value="referenceValue"/) + const adminOrderSources = `${adminList}\n${adminDetail}` + assert.match(adminOrderSources, /(?:clipboard\.writeText|copyText|copyToClipboard)[\s\S]{0,240}(?:orderNo|referenceValue)|(?:orderNo|referenceValue)[\s\S]{0,240}(?:clipboard\.writeText|copyText|copyToClipboard)/) +}) From ecf48886d5aea98d9dcc4dd6edf8298b3f12f826 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:34:30 +0800 Subject: [PATCH 09/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/service/LoadingPickService.java | 14 +- .../service/LoadingPickServiceTest.java | 33 ++++ .../controller/InboundPlanController.java | 13 ++ .../ConvertContainerInboundPlanRequest.java | 10 ++ .../mapper/InboundPlanOrderMapper.java | 6 + .../service/InboundPlanService.java | 24 +++ .../openapi/overseas-business-v1.yaml | 15 ++ .../container-inbound-plan-batch-contract.mjs | 58 +++++++ .../phase4-container-inbound-plan-batch.mjs | 151 ++++++++++++++++++ ...ainer-inbound-plan-batch-contract.test.mjs | 104 ++++++++++++ 10 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java create mode 100644 scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs create mode 100644 scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs create mode 100644 scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index eb919c242..4fca039ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -802,6 +802,13 @@ public class LoadingPickService { } private Task view(LoadingPickTaskEntity task, Long warehouseId) { + var booking = repository.activeBookingByTask(task.getId()); + if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); + } + if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库"); + } List source = warehouseId == null ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); Map> cartonsByItem = repository.cartonLabels(task.getId()).stream() @@ -828,17 +835,14 @@ public class LoadingPickService { : "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED" : assigned == null ? "UNASSIGNED" : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; - var booking = repository.activeBookingByTask(task.getId()); - if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { - throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); - } var owners = repository.inboundOwners(task.getId()); Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId(); var owner = detailInboundId == null ? null : owners.stream() .filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null); var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null ? null : repository.inbound(owner.tenantId(), owner.inboundId()); - LoadingCompletionCommandEntity command = completionCommands.latest(task.getId()); + LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus()) + ? null : completionCommands.latest(task.getId()); CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(), command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(), command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 0263816f7..f5ad4b8e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -92,6 +92,7 @@ class LoadingPickServiceTest { ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); booking.setContainerNo("CSLU1234567"); + booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.activeBookingByTask(20L)).thenReturn(booking); @@ -242,6 +243,7 @@ class LoadingPickServiceTest { task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); booking.setContainerNo("MSCU-NEW"); + booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task); when(repository.taskByPlan(10L, 101L)).thenReturn(task); @@ -424,6 +426,37 @@ class LoadingPickServiceTest { assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.mergedIntoContainerBookingId()).isNull(); + verify(completionCommands, never()).latest(20L); + } + + @Test + void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.inboundOwners(20L)).thenReturn(List.of()); + doThrow(new IllegalStateException("completion command schema unavailable")) + .when(completionCommands).latest(20L); + + var result = service.task(20L, 101L); + + assertThat(result.taskStatus()).isEqualTo("LOADING"); + assertThat(result.completionCommand()).isNull(); + verify(completionCommands, never()).latest(20L); + } + + @Test + void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + + assertThatThrownBy(() -> service.task(20L, 999L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库"); + + verify(repository, never()).items(20L, 999L); + verify(repository, never()).cartonLabels(20L); + verify(completionCommands, never()).latest(20L); } @Test diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 841d24fd6..42772e141 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -52,6 +53,18 @@ public class InboundPlanController { return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); } + @PostMapping("/convert-container") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + public ApiResponse> convertContainer( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ConvertContainerInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可按整柜生成入库计划"); + } + return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId())); + } + @GetMapping("/{planNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") /** 查询入仓计划及其明细、托盘和分配信息。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java new file mode 100644 index 000000000..b0908fdda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** 平台按来源头程柜预订身份批量生成入库计划。 */ +public record ConvertContainerInboundPlanRequest( + @NotBlank @Size(max = 128) String sourceContainerBookingId +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 56d3dda53..544252195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -12,6 +12,12 @@ import java.util.List; @Mapper public interface InboundPlanOrderMapper { + record ContainerOrderRef(String customerId, Long orderId, String currentStatus) { } + + @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus " + + "FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") + List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5509c1510..3ec5eca9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -121,6 +121,30 @@ public class InboundPlanService { return detail(customerId, plan); } + /** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */ + @Transactional + public List convertContainer(String sourceContainerBookingId) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); + List refs = orderMapper.lockContainerOrders(bookingId); + if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); + boolean hasEligible = refs.stream().anyMatch(ref -> "PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus())); + if (!hasEligible) { + List replay = refs.stream().map(ref -> { + InboundPlan existing = planMapper.findBySourceOrder(ref.customerId(), ref.orderId()); + if (existing == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜转换状态不一致"); + return detail(ref.customerId(), existing); + }).toList(); + return replay; + } + if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); + } + return refs.stream().map(ref -> convert(ref.customerId(), ref.orderId())).toList(); + } + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { 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 222db0c4b..e493e5f62 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -172,6 +172,21 @@ paths: customerId: { type: string, pattern: '^[1-9][0-9]*$' } orderId: { type: string, pattern: '^[1-9][0-9]*$' } responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } } + /api/platform/v1/last-leg/inbound-plans/convert-container: + post: + summary: 按整柜批量生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceContainerBookingId] + properties: + sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 } + responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 diff --git a/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs new file mode 100644 index 000000000..b3d0cf419 --- /dev/null +++ b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs @@ -0,0 +1,58 @@ +export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert' +export const CONTAINER_CONVERT_EXPECTED_ORDERS = 15 + +export function groupOrdersByCustomer(orders) { + if (!Array.isArray(orders)) return new Map() + const result = new Map() + for (const order of orders) { + const customerId = String(order?.customerId ?? '').trim() + if (!customerId) return new Map() + result.set(customerId, [...(result.get(customerId) || []), order]) + } + return result +} + +export function validContainerBatchResult(result, sourceOrders) { + const plans = Array.isArray(result?.plans) ? result.plans : [] + if (!Array.isArray(sourceOrders) || !sourceOrders.length || plans.length !== sourceOrders.length) return false + const sourceIds = new Set(sourceOrders.map((order) => String(order.orderId))) + const resultIds = plans.flatMap((plan) => Array.isArray(plan?.lines) ? plan.lines : []) + .map((line) => String(line?.orderId)) + return resultIds.length === sourceIds.size + && new Set(resultIds).size === sourceIds.size + && resultIds.every((orderId) => sourceIds.has(orderId)) + && plans.every((plan) => { + const lines = Array.isArray(plan?.lines) ? plan.lines : [] + const expected = sourceOrders.find((order) => String(order.orderId) === String(lines[0]?.orderId)) + return plan?.status === 'PENDING_ASSIGNMENT' + && lines.length === 1 && expected != null + && String(plan?.customerId ?? '') === String(expected.customerId) + && String(plan?.planNo ?? '').length > 0 + && Array.isArray(plan?.pallets) && Array.isArray(plan?.allocations) + }) +} + +export function validOrderFact(actual, expected) { + const exact = (field) => (actual?.[field] ?? null) === (expected?.[field] ?? null) + return ['packageCount', 'volumeCbm', 'productQuantity', 'volumeWeightKg', + 'palletCount', 'estimatedArrivalAt', 'actualArrivalAt', 'estimatedWarehouseAt'].every(exact) +} + +export function distinctPhysicalPalletCount(rows) { + if (!Array.isArray(rows)) return 0 + return new Set(rows.map((row) => String(row?.physicalPalletId ?? '').trim()).filter(Boolean)).size +} + +export function zeroDrift(before, after) { + return JSON.stringify(before) === JSON.stringify(after) +} + +export function stableReplay(first, replay) { + return zeroDrift(first, replay) +} + +export function validPdaCandidate(candidate, expected) { + return String(candidate?.containerNo ?? '') === String(expected?.containerNo ?? '') + && Number(candidate?.planCount) === Number(expected?.planCount) + && Number(candidate?.inboundCodeCount) === Number(expected?.inboundCodeCount) +} diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs new file mode 100644 index 000000000..dbfe33ebe --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -0,0 +1,151 @@ +import { + CONTAINER_CONVERT_ENDPOINT, + CONTAINER_CONVERT_EXPECTED_ORDERS, + validContainerBatchResult, + validOrderFact, + validPdaCandidate, + zeroDrift, +} from './container-inbound-plan-batch-contract.mjs' + +const MODULE = 'phase4-container-inbound-plan-batch' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = (value) => value == null ? '' : String(value) +const records = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] +const success = (response) => response?.http === 200 && String(response?.body?.code) === '200' +const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +const rejected = (response, codes) => response?.http === 200 && codes.includes(String(response?.body?.code)) + +function gap(context, name, reason, priority = 'P0') { + context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: priority === 'P0' }) + context.addStep(MODULE, name, null, false, `${priority}: ${reason}`, OPTIONS) + throw new Error(`${priority} blocking coverage gap: ${name}: ${reason}`) +} + +function fixture(context) { + const value = context.createdResources?.phase4ContainerInboundPlanFixture + if (value?.marker !== context.marker || !value?.normal?.containerNo + || !Array.isArray(value.normal.orders) || value.normal.orders.length !== CONTAINER_CONVERT_EXPECTED_ORDERS) { + gap(context, '整柜15票新数据前置', '缺少本轮marker的15票同柜正常fixture,禁止借用历史已转换数据') + } + for (const name of ['invalid', 'partial']) { + if (!value[name]?.containerNo || !Array.isArray(value[name]?.orders)) { + gap(context, `${name}整柜前置`, `缺少本轮${name}专用fixture,不能验证整批零漂移`, 'P1') + } + } + if (!value.pdaToken) gap(context, 'PDA候选Token前置', '缺少绑定真实海外仓的WAREHOUSE_PICKER海外Token') + return value +} + +async function listOrders(context, containerNo) { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) } +} + +async function snapshot(context, source) { + const listed = await listOrders(context, source.containerNo) + const plans = [] + for (const order of listed.rows) { + if (!order?.inboundCode) continue + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`) + plans.push(...records(response?.body?.data).map((row) => ({ + planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status, + }))) + } + return { + orders: listed.rows.map((row) => ({ + id: id(row.id), customerId: id(row.customerId), currentStatus: row.currentStatus, + inboundOrderNo: row.inboundOrderNo ?? null, palletCount: row.palletCount ?? null, + })).sort((a, b) => a.id.localeCompare(b.id)), + plans: plans.sort((a, b) => `${a.planId}:${a.orderId}`.localeCompare(`${b.planId}:${b.orderId}`)), + } +} + +function convertBody(source) { + const anchor = source.orders[0] + return { orderId: id(anchor.orderId), customerId: id(anchor.customerId) } +} + +async function convert(context, name, source, key) { + const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT, + convertBody(source), {}, context.overseasToken, { 'Idempotency-Key': key }) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return response +} + +export async function run(context) { + const data = fixture(context) + const normalList = await listOrders(context, data.normal.containerNo) + context.assert(MODULE, 'P0同柜15票列表前置', normalList.response, + success(normalList.response) && normalList.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS + && normalList.rows.every((row) => typeof row.id === 'string' && typeof row.customerId === 'string'), + `orders=${normalList.rows.length}; ${trace(normalList.response)}`, OPTIONS) + + for (const expected of data.normal.orders) { + const actual = normalList.rows.find((row) => id(row.id) === id(expected.orderId)) + context.assert(MODULE, `P2订单事实不猜算:${id(expected.orderId)}`, normalList.response, + Boolean(actual) && validOrderFact(actual, expected), + `customerId=${id(expected.customerId)}, inboundCode=${expected.inboundCode || 'missing'}`, { ...OPTIONS, deploymentBlocker: false }) + } + + const invalidBefore = await snapshot(context, data.invalid) + const invalid = await convert(context, 'P1任一非法票整柜转换拒绝', data.invalid, `${context.marker}-invalid-container`) + const invalidAfter = await snapshot(context, data.invalid) + context.assert(MODULE, 'P1非法票整批零漂移', invalid, + rejected(invalid, ['A0400', 'A0409']) && zeroDrift(invalidBefore, invalidAfter), + trace(invalid), OPTIONS) + + const partialBefore = await snapshot(context, data.partial) + const partial = await convert(context, 'P1部分已转换整柜冲突', data.partial, `${context.marker}-partial-container`) + const partialAfter = await snapshot(context, data.partial) + context.assert(MODULE, 'P1部分转换冲突零漂移', partial, + rejected(partial, ['A0409']) && zeroDrift(partialBefore, partialAfter), trace(partial), OPTIONS) + + const key = `${context.marker}-container-convert` + const first = await convert(context, 'P0整柜一次转换并逐订单生成独立计划', data.normal, key) + const result = first?.body?.data + context.assert(MODULE, 'P0整柜响应覆盖全部订单和客户计划', first, + success(first) && validContainerBatchResult(result, data.normal.orders), trace(first), OPTIONS) + + const replay = await convert(context, 'P1同键同请求重放', data.normal, key) + context.assert(MODULE, 'P1重放计划号与明细零新增', replay, + success(replay) && zeroDrift(result, replay?.body?.data), trace(replay), OPTIONS) + + const concurrentKey = `${context.marker}-container-double-click` + const concurrent = await Promise.all([ + convert(context, 'P1并发双击A', data.normal, concurrentKey), + convert(context, 'P1并发双击B', data.normal, concurrentKey), + ]) + context.assert(MODULE, 'P1并发双击收敛同一批计划', concurrent[1], + concurrent.every(success) && zeroDrift(concurrent[0].body?.data, concurrent[1].body?.data), + concurrent.map(trace).join('; '), OPTIONS) + + const after = await listOrders(context, data.normal.containerNo) + context.assert(MODULE, 'P0全部15票推进INBOUND_PLANNED', after.response, + success(after.response) && after.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS + && after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo), + `planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS) + + const candidateResponse = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, data.pdaToken) + const candidate = Array.isArray(candidateResponse?.body?.data) + ? candidateResponse.body.data.find((row) => row?.containerNo === data.normal.containerNo) : null + context.assert(MODULE, 'P0同柜PDA候选计划数与入仓码数一致', candidateResponse, + success(candidateResponse) && validPdaCandidate(candidate, { + containerNo: data.normal.containerNo, + planCount: data.normal.orders.length, + inboundCodeCount: data.normal.orders.length, + }), trace(candidateResponse), OPTIONS) + + const carton = data.normal.orders.flatMap((order) => order.cartonMarks || []) + .map((cartonMark) => ({ cartonMark })).at(0) + if (!carton) gap(context, 'P3箱码命中fixture', '正常整柜没有可用于resolve的冻结箱码', 'P3') + const prepared = data.preparedSession + if (!prepared) gap(context, 'P3箱码命中会话', '需先由PDA合同完成prepare+三照片+sign,再执行resolve', 'P3') + const resolved = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', carton, {}, data.pdaToken) + context.assert(MODULE, 'P3箱码服务端命中当前柜唯一入仓码', resolved, + success(resolved) && data.normal.orders.some((order) => order.inboundCode === resolved?.body?.data?.inboundCode), + trace(resolved), { ...OPTIONS, deploymentBlocker: false }) +} diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs new file mode 100644 index 000000000..783bdbab3 --- /dev/null +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + CONTAINER_CONVERT_ENDPOINT, + CONTAINER_CONVERT_EXPECTED_ORDERS, + distinctPhysicalPalletCount, + groupOrdersByCustomer, + stableReplay, + validContainerBatchResult, + validOrderFact, + validPdaCandidate, + zeroDrift, +} from './container-inbound-plan-batch-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const scenario = read('phase4-container-inbound-plan-batch.mjs') +const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java') +const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java') +const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +const pda = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java') +const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') + +function orders() { + return Array.from({ length: 15 }, (_, index) => ({ + orderId: String(9_000 + index), customerId: index < 10 ? '101' : '202', + packageCount: 2, volumeCbm: '1.2500', productQuantity: 20, + palletCount: 1, volumeWeightKg: '18.750', + estimatedArrivalAt: '2026-09-01T08:30:00', actualArrivalAt: null, estimatedWarehouseAt: null, + })) +} + +test('semantic contract creates one independent plan per order even for the same customer', () => { + const source = orders() + assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15) + assert.equal(groupOrdersByCustomer(source).size, 2) + const result = { plans: source.map((order, index) => ({ + customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT', + lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}], + })) } + assert.equal(validContainerBatchResult(result, source), true) +}) + +test('facts preserve source values and physical pallets are not counted once per SKU', () => { + const expected = orders()[0] + assert.equal(validOrderFact({ ...expected }, expected), true) + assert.equal(validOrderFact({ ...expected, actualArrivalAt: '2026-09-02T08:00:00' }, expected), false) + assert.equal(distinctPhysicalPalletCount([ + { physicalPalletId: 'P1', sku: 'A' }, { physicalPalletId: 'P1', sku: 'B' }, { physicalPalletId: 'P2', sku: 'A' }, + ]), 2) +}) + +test('atomic failure, replay and PDA count helpers reject drift', () => { + const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] } + assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true) + assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true) + assert.equal(validPdaCandidate({ containerNo: 'C1', planCount: '2', inboundCodeCount: '15' }, + { containerNo: 'C1', planCount: 2, inboundCodeCount: 15 }), true) +}) + +test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => { + assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert') + for (const marker of ['P0整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', + 'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突', + 'P0同柜PDA候选计划数与入仓码数一致', 'P3箱码服务端命中当前柜唯一入仓码']) { + assert.match(scenario, new RegExp(marker)) + } + assert.match(scenario, /Promise\.all/) + assert.match(scenario, /Idempotency-Key/) + assert.match(scenario, /requestId/) + assert.match(scenario, /traceId/) +}) + +test('platform API keeps two long string identifiers and Admin uses the selected row customer', () => { + const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`), + openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`))) + assert.match(endpoint, /required: \[customerId, orderId\]/) + assert.match(endpoint, /customerId:\s*\{ type: string/) + assert.match(endpoint, /orderId:\s*\{ type: string/) + assert.match(controller, /convertScope\(user, request\.customerId\(\)\)/) + assert.match(adminApi, /orderId: string[\s\S]{0,80}customerId: string/) + assert.match(adminView, /selectedRows\.value\.length !== 1/) + assert.match(adminView, /customerId = typeof selected\.customerId === 'string' \? selected\.customerId\.trim\(\)/) + assert.match(adminView, /orderId: String\(selected\.id\), customerId/) +}) + +test('backend batch conversion is one transaction and never merges same-customer orders', () => { + assert.match(controller, /ApiResponse<[^>]*(?:Batch|List|Container)[^>]*>/) + assert.match(service, /@Transactional[\s\S]{0,120}(?:convertContainer|convertBatch|convert)/) + assert.match(service, /(?:List|Container.*Response|Batch.*Response)/) + assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/) + assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/) + assert.match(service, /(?:partial|部分已转换|already belongs)[\s\S]{0,180}(?:CONFLICT|HttpStatus\.CONFLICT)/i) +}) + +test('PDA candidate and resolve contracts remain server-owned', () => { + assert.match(pda, /grouped\.computeIfAbsent\(plan\.getContainerNo\(\)/) + assert.match(pda, /entry\.getValue\(\)\.size\(\)/) + assert.match(pda, /totals\.inboundCount\(\)/) + assert.match(pda, /requireReceivingAvailable\(customerId, containerNo\)/) + assert.match(pda, /requireCartonRef|requireUnmatchedFbaCartonPrefix/) +}) From 61a97cd057ed32a6376f6bfe5b67d08cd912b3f6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:42:27 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV1.java | 24 +++++++++- .../event/FirstLegLoadingPlanCompletedV2.java | 26 ++++++++++- .../openapi/cross-region-events-v1.yaml | 11 ++++- .../FirstLegLoadingPlanCompletedV1Test.java | 11 ++++- .../FirstLegLoadingPlanCompletedV2Test.java | 27 ++++++++++- .../mapper/LoadingCompletedEventMapper.java | 11 ++++- .../LoadingCompletedOutboxService.java | 18 ++++++-- .../LoadingCompletedOutboxServiceTest.java | 46 ++++++++++++++++++- .../TaillegRealProducerAcceptanceTest.java | 36 +++++++++++---- 9 files changed, 187 insertions(+), 23 deletions(-) diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 8c9a4578d..da09521dc 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -7,7 +7,14 @@ import java.util.List; public record FirstLegLoadingPlanCompletedV1( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, List orders) { + public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, orders); + } public record Order( String sourceInboundId, String rootInboundId, @@ -16,6 +23,7 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, String orderType, List pallets, String destinationType) { public Order { @@ -40,9 +48,23 @@ public record FirstLegLoadingPlanCompletedV1( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, orderType, pallets, null); } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType); + } } public record Item( diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index cf908f2f6..261939432 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -7,7 +7,15 @@ import java.util.List; public record FirstLegLoadingPlanCompletedV2( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, List orders) { + public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, orders); + } + public record Order( String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, @@ -15,6 +23,7 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, String orderType, List pallets, /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ String destinationType) { @@ -41,9 +50,24 @@ public record FirstLegLoadingPlanCompletedV2( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, orderType, pallets, null); } + + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType); + } } public record Item( diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml index aadee97be..39b7d97f6 100644 --- a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -7,7 +7,7 @@ components: schemas: FirstLegLoadingPlanCompletedV2: type: object - required: [loadingPlanId, containerBookingId, orders] + required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] properties: loadingPlanId: {type: string} containerBookingId: {type: string} @@ -15,12 +15,15 @@ components: cabinetNo: {type: string} warehouseId: {type: string} warehouseName: {type: string} + estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact} + actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate} + estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact} orders: type: array items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'} FirstLegLoadingPlanCompletedOrderV2: type: object - required: [sourceInboundId, customerId, customerName, packageCount, items, orderType, pallets, destinationType] + required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType] properties: sourceInboundId: {type: string} sourceOrderId: {type: string} @@ -29,6 +32,10 @@ components: customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg} customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM} packageCount: {type: integer, minimum: 1} + productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable} + palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder} + volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres} + volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable} items: {type: array, items: {type: object}} orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} pallets: {type: array, items: {type: object}} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index fe22ac20c..c364654b2 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -52,13 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test { void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { String json = """ {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", - "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", - "destinationWarehouseId":"3","packageCount":1,"items":[],"customerId":"9001", + "warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00", + "actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00", + "orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1, + "palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001", "customerName":"客户甲","destinationType":"OVERSEAS"}]} """; FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00"); + assertThat(restored.actualPortArrivalAt()).isNull(); + assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00"); + assertThat(restored.orders().get(0).palletCount()).isEqualTo(2); + assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500"); } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 812c1603e..d90ea1c4e 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -4,12 +4,35 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.List; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { + @Test + void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8", + 12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"), + List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS"); + var payload = new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", + "2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order)); + + var json = new ObjectMapper().valueToTree(payload); + + assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); + assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3); + assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12); + assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120); + assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500"); + assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750"); + } + @Test void carriesTheThreeItemRemarks() throws Exception { var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, @@ -92,6 +115,8 @@ class FirstLegLoadingPlanCompletedV2Test { .contains("required: [sourceInboundId, customerId, customerName") .contains("customerId: {type: string, minLength: 1") .contains("customerName: {type: string, minLength: 1, maxLength: 128") - .contains("consumers must not query domestic IAM"); + .contains("consumers must not query domestic IAM") + .contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt") + .contains("palletCount", "volumeWeightKg", "never a planned zero placeholder"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index d75cc6c33..e0cab29ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; /** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ @@ -16,6 +17,8 @@ public interface LoadingCompletedEventMapper { SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, b.customer_id AS booking_customer_id, b.customer_name, + b.estimated_port_arrival_at, b.actual_port_arrival_at, + b.estimated_warehouse_arrival_at, i.account_id, i.inbound_id AS source_inbound_id, COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, @@ -50,7 +53,8 @@ public interface LoadingCompletedEventMapper { /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, - a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, + a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no, + a.picked_package_count AS allocated_package_count, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.package_count, i.product_quantity, label.box_code @@ -82,6 +86,8 @@ public interface LoadingCompletedEventMapper { private Integer aggregateVersion; private String containerNo; private String cabinetNo; private Long warehouseId; private String warehouseName; private Long bookingCustomerId; private String customerName; private Long accountId; + private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt; + private LocalDateTime estimatedWarehouseArrivalAt; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; @@ -97,7 +103,8 @@ public interface LoadingCompletedEventMapper { @Getter @Setter class PalletRow { - private Long accountId; private Long sourceInboundId; private String palletCode; + private Long accountId; private Long sourceInboundId; private Long palletId; + private Long reservationId; private String palletCode; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; private Integer packageCount; private Integer productQuantity; private String boxCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 361e2bb67..6dd1b3330 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -68,7 +68,8 @@ public class LoadingCompletedOutboxService { .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), - string(first.getWarehouseId()), first.getWarehouseName(), orders); + string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()), + iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders); JsonNode payloadNode = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; @@ -103,12 +104,20 @@ public class LoadingCompletedOutboxService { string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), - sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) - ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, + sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows), + sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items, + string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, destinationType(sorted)); } + /** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */ + private static Integer actualPalletCount(List palletRows) { + Set palletIds = palletRows.stream().map(PalletRow::getPalletId) + .filter(Objects::nonNull).filter(value -> value > 0).collect(java.util.stream.Collectors.toSet()); + return palletIds.isEmpty() ? null : palletIds.size(); + } + private void validateCustomerSnapshots(List rows) { for (Row row : rows) { if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) { @@ -275,6 +284,7 @@ public class LoadingCompletedOutboxService { private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } } private static String string(Long value) { return value == null ? null : String.valueOf(value); } + private static String iso(java.time.LocalDateTime value) { return value == null ? null : value.toString(); } private static int sumInt(List rows, java.util.function.Function getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); } private static Integer sumNullableInt(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); } private static BigDecimal sum(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 4d89c4602..8128fcda0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest { @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101))); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"), + pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"), + pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102"))); service.publish(90L); ArgumentCaptor captor=ArgumentCaptor.forClass(InboundOutboxEntity.class); verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture()); @@ -60,6 +64,14 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11"); + assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20); + assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2"); + assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4"); + assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30"); + assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -148,6 +160,28 @@ class LoadingCompletedOutboxServiceTest { .isNotEqualTo(captor.getAllValues().get(1).getPayloadHash()); } + @Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception { + Row value = row(11, 100, 101); + value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null); + value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null); + value.setEstimatedWarehouseArrivalAt(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + when(snapshots.palletRows(90L)).thenReturn(List.of()); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue(); + } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { Row first = row(11, 100, 101); first.setPackageCount(1); Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); @@ -176,7 +210,9 @@ class LoadingCompletedOutboxServiceTest { assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'") .contains("b.customer_id AS booking_customer_id") - .contains("b.customer_name"); + .contains("b.customer_name") + .contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at") + .contains("b.estimated_warehouse_arrival_at"); for (String sql : List.of(rowsSql, palletsSql)) { assertThat(sql) .contains("actual_pick.loading_plan_item_id = i.id") @@ -190,6 +226,7 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("'PICKING'"); } assertThat(palletsSql) + .contains("a.pallet_id", "a.reservation_id") .contains("label.tenant_id = i.account_id") .contains("label.inbound_detail_id = i.inbound_detail_id") .contains("label.batch_status = 'ACTIVE'") @@ -252,6 +289,9 @@ class LoadingCompletedOutboxServiceTest { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account); + r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); + r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); + r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0)); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); @@ -259,13 +299,15 @@ class LoadingCompletedOutboxServiceTest { r.setDestinationType("OVERSEAS"); r.setFulfillmentType("STANDARD"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); - r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; + r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2")); + r.setVolumeCbm(BigDecimal.ONE); return r; } private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, long detail,String sku,int packages,int quantity,String boxCode) { PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); + row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation); row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated); row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index 085ce11eb..ef55c558c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; @SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { @@ -53,13 +54,26 @@ class TaillegRealProducerAcceptanceTest { assertEquals("真实验收客户", jdbc.queryForObject( "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals("2026-09-30T12:00", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertNull(jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals(1, jdbc.queryForObject( + "SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("35.0", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); } private void seedCompletedLoadingPlan() { jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + - "warehouse_name,customer_id,customer_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,1,1)", + "warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,1,1)", BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", - CUSTOMER, "真实验收客户", "40HQ"); + CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); @@ -70,12 +84,8 @@ class TaillegRealProducerAcceptanceTest { MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3); for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L, MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2); - jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + - "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + - "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + - "reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", - 990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D", - 1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES"); + seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F"); + seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D"); jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," + "container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')", 990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR"); @@ -84,6 +94,16 @@ class TaillegRealProducerAcceptanceTest { jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN); } + private void seedAllocation(long id, long inboundId, long palletId, long locationId, + int packages, String suffix) { + jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + + "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + + "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + + "reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", + id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix, + 1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix); + } + private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) { jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," + "inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')", From 76c24867a49e30e00fb454d65158d7ce0d2b55e2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:16:33 +0800 Subject: [PATCH 11/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + lastleg-service/AGENTS.md | 4 +- .../controller/LastlegOrderController.java | 13 +++ .../lastleg/dto/LastlegOrderRequests.java | 13 +++ .../lastleg/dto/PendingContainerSummary.java | 18 ++++ .../service/InboundPlanService.java | 56 +++++++++--- .../lastleg/mapper/LastlegOrderMapper.java | 34 ++++++++ .../lastleg/service/LastlegOrderService.java | 16 ++++ .../src/main/resources/db/lastleg.sql | 5 +- .../openapi/overseas-business-v1.yaml | 44 ++++++++++ .../LastlegOrderControllerTest.java | 18 ++++ .../controller/InboundPlanControllerTest.java | 18 ++++ .../service/InboundPlanServiceTest.java | 87 +++++++++++++++++++ .../service/LastlegOrderServiceTest.java | 30 +++++++ .../PendingContainerSummaryContractTest.java | 46 ++++++++++ .../container-inbound-plan-batch-contract.mjs | 28 +++--- .../phase4-container-inbound-plan-batch.mjs | 61 +++++-------- ...ainer-inbound-plan-batch-contract.test.mjs | 63 ++++++-------- scripts/agents-index-contract.ps1 | 26 +++++- 19 files changed, 474 insertions(+), 107 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java diff --git a/AGENTS.md b/AGENTS.md index cba94bc2d..f83882030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 待分配柜级摘要与整柜入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合分页;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等;详见 `lastleg-service/AGENTS.md` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 2e012ad41..df6034517 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -11,7 +11,7 @@ | 功能 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | -| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;待分配柜级摘要固定为 `/api/platform/v1/last-leg/orders/container-summaries`,按 `sourceContainerBookingId` 跨客户聚合分页,不允许前端本地拼柜 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | @@ -21,7 +21,7 @@ | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | | IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | -| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | +| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 7fd3375d5..85e6e4df6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.PendingContainerSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; @@ -71,6 +72,18 @@ public class LastlegOrderController { return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); } + @GetMapping("/api/platform/v1/last-leg/orders/container-summaries") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "分页查询待分配柜级摘要", + description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse> containerSummaries( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid LastlegOrderRequests.PendingContainerQuery query) { + platformScope(user, null); + return ApiResponse.success(orderService.pendingContainerPage(query)); + } + @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 99bf49851..d04b41dea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -36,6 +36,19 @@ public final class LastlegOrderRequests { } } + public record PendingContainerQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String containerNo, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public PendingContainerQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + public record TransferDestination( @NotEmpty @Size(max = 200) List orderIds, @NotBlank @Size(max = 128) String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java new file mode 100644 index 000000000..3b1769216 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dto; + +import java.math.BigDecimal; + +/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */ +public record PendingContainerSummary( + String sourceContainerBookingId, + String containerNo, + long orderCount, + long inboundCodeCount, + long customerCount, + long totalPackageCount, + long totalPalletCount, + BigDecimal totalVolumeCbm, + boolean eligible, + String ineligibleReason +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 3ec5eca9f..9181793b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -31,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.ArrayList; import java.util.stream.Collectors; @Service @@ -73,7 +74,16 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); + return createPlan(customerId, selectedOrderId, orders); + } + private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, + List orders) { + String containerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound order container number is empty"); + } LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); plan.setId(IdWorker.getId()); @@ -128,21 +138,47 @@ public class InboundPlanService { if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); List refs = orderMapper.lockContainerOrders(bookingId); if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); - boolean hasEligible = refs.stream().anyMatch(ref -> "PENDING_ASSIGNMENT".equals(ref.currentStatus()) - || "DESTINATION_ASSIGNED".equals(ref.currentStatus())); - if (!hasEligible) { - List replay = refs.stream().map(ref -> { - InboundPlan existing = planMapper.findBySourceOrder(ref.customerId(), ref.orderId()); - if (existing == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜转换状态不一致"); - return detail(ref.customerId(), existing); - }).toList(); - return replay; + List existingPlans = refs.stream() + .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) + .toList(); + long existingCount = existingPlans.stream().filter(Objects::nonNull).count(); + if (existingCount == refs.size()) { + List replay = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); + } + return List.copyOf(replay); + } + if (existingCount != 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划"); } if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); } - return refs.stream().map(ref -> convert(ref.customerId(), ref.orderId())).toList(); + + List> snapshots = new ArrayList<>(refs.size()); + String containerNo = null; + for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { + List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); + } + validateOrders(orders); + String currentContainerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) containerNo = currentContainerNo; + if (!Objects.equals(containerNo, currentContainerNo)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); + } + snapshots.add(orders); + } + + List created = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); + created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index))); + } + return List.copyOf(created); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 9973bdca2..eb6766c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -2,13 +2,47 @@ package com.baiyue.lastleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.PendingContainerSummary; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface LastlegOrderMapper extends BaseMapper { + String PENDING_CONTAINER_FILTER = " FROM ll_order o " + + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + + "AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') " + + "AND o.created_at >=#{q.createdFrom} " + + "AND o.created_at <=#{q.createdTo} " + + "GROUP BY o.source_container_booking_id " + + "HAVING SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)>0 "; + + @Select("") + List pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query); + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") LastlegOrder findSource(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 8b88d3694..bb61120ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.PendingContainerSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -46,6 +47,21 @@ public class LastlegOrderService { page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + @Transactional(readOnly = true) + public PageResult pendingContainerPage( + LastlegOrderRequests.PendingContainerQuery query) { + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "createdFrom must not be after createdTo"); + } + long total = orderMapper.countPendingContainers(query); + List records = total == 0 ? List.of() + : orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2a5382912..34be04951 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -100,7 +100,8 @@ CREATE TABLE IF NOT EXISTS ll_order ( UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id), KEY idx_ll_order_group(customer_id,order_no), KEY idx_ll_order_container(customer_id,container_no), - KEY idx_ll_order_list(customer_id,current_status,created_at) + KEY idx_ll_order_list(customer_id,current_status,created_at), + KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at) ) ENGINE=InnoDB COMMENT='尾程订单主表'; CREATE TABLE IF NOT EXISTS ll_order_detail ( @@ -198,6 +199,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)')); +PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); 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 e493e5f62..478471b24 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -156,6 +156,36 @@ paths: - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } + /api/platform/v1/last-leg/orders/container-summaries: + get: + summary: 分页查询待分配柜级摘要 + description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: containerNo, in: query, schema: { type: string } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: + '200': + description: 统一 ApiResponse 包装的柜级摘要分页 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: + type: object + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + records: + type: array + items: { $ref: '#/components/schemas/PendingContainerSummary' } /api/platform/v1/last-leg/inbound-plans/convert: post: summary: 平台生成入库计划 @@ -562,6 +592,20 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + PendingContainerSummary: + type: object + required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible] + properties: + sourceContainerBookingId: { type: string } + containerNo: { type: string, nullable: true } + orderCount: { type: integer, format: int64 } + inboundCodeCount: { type: integer, format: int64 } + customerCount: { type: integer, format: int64 } + totalPackageCount: { type: integer, format: int64 } + totalPalletCount: { type: integer, format: int64 } + totalVolumeCbm: { type: number } + eligible: { type: boolean } + ineligibleReason: { type: string, nullable: true } PdaWarehouseScan: type: object required: [code] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index abb7991c9..c1b6b222b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -50,6 +50,24 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void platformOperatorCanQueryCrossCustomerContainerSummaries() { + var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null); + + controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery); + + verify(service).pendingContainerPage(summaryQuery); + } + + @Test + void containerSummariesRejectMerchantIdentity() { + var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null); + + assertThrows(ResponseStatusException.class, + () -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery)); + verify(service, never()).pendingContainerPage(summaryQuery); + } + @Test void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { EventEnvelope event = mock(EventEnvelope.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 8a72de63b..ab9ad7916 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -87,6 +88,23 @@ class InboundPlanControllerTest { verify(service).convert("100", 2L); } + @Test + void platformContainerConversionUsesStableBookingIdentity() { + controller.convertContainer(platform(), new ConvertContainerInboundPlanRequest(" BOOKING-1 ")); + + verify(service).convertContainer(" BOOKING-1 "); + } + + @Test + void merchantCannotConvertAWholeContainer() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.convertContainer(customer, + new ConvertContainerInboundPlanRequest("BOOKING-1"))) + .isInstanceOf(ResponseStatusException.class); + verify(service, never()).convertContainer(org.mockito.ArgumentMatchers.any()); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 1229d01ce..76e3dcdd3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -191,6 +191,93 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void containerConversionPrevalidatesEveryOrderBeforeWritingIndependentPlans() throws Exception { + assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class) + .getAnnotation(Transactional.class)); + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convertContainer(" BOOKING-1 "); + + assertEquals(2, result.size()); + assertEquals(1L, result.get(0).sourceOrderId()); + assertEquals(2L, result.get(1).sourceOrderId()); + verify(planMapper, times(2)).insert(any(InboundPlan.class)); + verify(orderMapper).lockContainerOrders("BOOKING-1"); + verify(orderMapper).lockOrder("100", 1L); + verify(orderMapper).lockOrder("200", 2L); + } + + @Test + void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0))); + + assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + } + + @Test + void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { + var refs = List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED")); + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2")); + + var result = service.convertContainer("BOOKING-1"); + + assertEquals(List.of("LIP-1", "LIP-2"), result.stream().map(value -> value.planNo()).toList()); + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + @Test + void partialExistingPlansAreRejectedBeforeAnyWrite() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + + assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + private InboundPlan existingPlan(String customerId, long orderId, String planNo) { + InboundPlan plan = new InboundPlan(); + plan.setId(orderId + 100); + plan.setPlanNo(planNo); + plan.setCustomerId(customerId); + plan.setSourceOrderId(orderId); + plan.setContainerNo("CONT-1"); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(1); + plan.setPalletCount(1); + plan.setTotalVolumeCbm(BigDecimal.ONE); + return plan; + } + + private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) { + return new SourceOrderSnapshot(id, customerId, "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, + BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0); + } + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 9f7a4f9ab..a45fc5a7a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.PendingContainerSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -19,6 +20,8 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.math.BigDecimal; +import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -133,6 +136,33 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void pendingContainerPageUsesServerSideAggregatePagination() { + var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null); + var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3, + 150, 0, new BigDecimal("12.5000"), true, null); + when(orders.countPendingContainers(query)).thenReturn(11L); + when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row)); + + var result = service.pendingContainerPage(query); + + assertEquals(2, result.page()); + assertEquals(10, result.size()); + assertEquals(11, result.total()); + assertEquals(2, result.pages()); + assertEquals(List.of(row), result.records()); + } + + @Test + void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() { + var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, + LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query)); + + verify(orders, never()).countPendingContainers(any()); + } + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { LastlegOrder order = new LastlegOrder(); order.setId(id); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java new file mode 100644 index 000000000..4925f6f20 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.PendingContainerSummary; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PendingContainerSummaryContractTest { + @Test + void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception { + assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount", + "customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible", + "ineligibleReason"), + Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList()); + + Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers", + com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class) + .getAnnotation(Select.class); + String sql = String.join("", rows.value()).toLowerCase(); + assertTrue(sql.contains("group by o.source_container_booking_id")); + assertTrue(sql.contains("pending_assignment")); + assertTrue(sql.contains("destination_assigned")); + assertTrue(sql.contains("limit #{limit} offset #{offset}")); + } + + @Test + void openApiAndSchemaExposeStableContainerIdentity() throws Exception { + String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + + assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:")); + assertTrue(openApi.contains("PendingContainerSummary:")); + assertTrue(openApi.contains("sourceContainerBookingId:")); + assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)")); + } +} diff --git a/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs index b3d0cf419..a2520a32c 100644 --- a/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs +++ b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs @@ -1,4 +1,5 @@ -export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert' +export const CONTAINER_SUMMARY_ENDPOINT = '/api/platform/v1/last-leg/orders/container-summaries' +export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert-container' export const CONTAINER_CONVERT_EXPECTED_ORDERS = 15 export function groupOrdersByCustomer(orders) { @@ -13,7 +14,7 @@ export function groupOrdersByCustomer(orders) { } export function validContainerBatchResult(result, sourceOrders) { - const plans = Array.isArray(result?.plans) ? result.plans : [] + const plans = Array.isArray(result) ? result : [] if (!Array.isArray(sourceOrders) || !sourceOrders.length || plans.length !== sourceOrders.length) return false const sourceIds = new Set(sourceOrders.map((order) => String(order.orderId))) const resultIds = plans.flatMap((plan) => Array.isArray(plan?.lines) ? plan.lines : []) @@ -32,15 +33,14 @@ export function validContainerBatchResult(result, sourceOrders) { }) } -export function validOrderFact(actual, expected) { - const exact = (field) => (actual?.[field] ?? null) === (expected?.[field] ?? null) - return ['packageCount', 'volumeCbm', 'productQuantity', 'volumeWeightKg', - 'palletCount', 'estimatedArrivalAt', 'actualArrivalAt', 'estimatedWarehouseAt'].every(exact) -} - -export function distinctPhysicalPalletCount(rows) { - if (!Array.isArray(rows)) return 0 - return new Set(rows.map((row) => String(row?.physicalPalletId ?? '').trim()).filter(Boolean)).size +export function validContainerSummaryPage(data, expected) { + if (!Array.isArray(data?.records)) return false + const row = data.records.find((item) => String(item?.sourceContainerBookingId ?? '') + === String(expected?.sourceContainerBookingId ?? '')) + return Number(data.page) >= 1 && Number(data.size) >= 1 && Number(data.total) >= 1 + && Number(data.pages) >= 1 && String(row?.containerNo ?? '') === String(expected?.containerNo ?? '') + && Number(row?.orderCount) === Number(expected?.orderCount) + && Number(row?.inboundCodeCount) === Number(expected?.inboundCodeCount) } export function zeroDrift(before, after) { @@ -50,9 +50,3 @@ export function zeroDrift(before, after) { export function stableReplay(first, replay) { return zeroDrift(first, replay) } - -export function validPdaCandidate(candidate, expected) { - return String(candidate?.containerNo ?? '') === String(expected?.containerNo ?? '') - && Number(candidate?.planCount) === Number(expected?.planCount) - && Number(candidate?.inboundCodeCount) === Number(expected?.inboundCodeCount) -} diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs index dbfe33ebe..5784a027d 100644 --- a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -1,9 +1,9 @@ import { CONTAINER_CONVERT_ENDPOINT, CONTAINER_CONVERT_EXPECTED_ORDERS, + CONTAINER_SUMMARY_ENDPOINT, validContainerBatchResult, - validOrderFact, - validPdaCandidate, + validContainerSummaryPage, zeroDrift, } from './container-inbound-plan-batch-contract.mjs' @@ -15,15 +15,15 @@ const success = (response) => response?.http === 200 && String(response?.body?.c const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` const rejected = (response, codes) => response?.http === 200 && codes.includes(String(response?.body?.code)) -function gap(context, name, reason, priority = 'P0') { - context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: priority === 'P0' }) +function gap(context, name, reason, priority = 'P1') { + context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: true }) context.addStep(MODULE, name, null, false, `${priority}: ${reason}`, OPTIONS) throw new Error(`${priority} blocking coverage gap: ${name}: ${reason}`) } function fixture(context) { const value = context.createdResources?.phase4ContainerInboundPlanFixture - if (value?.marker !== context.marker || !value?.normal?.containerNo + if (value?.marker !== context.marker || !value?.normal?.containerNo || !value?.normal?.sourceContainerBookingId || !Array.isArray(value.normal.orders) || value.normal.orders.length !== CONTAINER_CONVERT_EXPECTED_ORDERS) { gap(context, '整柜15票新数据前置', '缺少本轮marker的15票同柜正常fixture,禁止借用历史已转换数据') } @@ -32,7 +32,6 @@ function fixture(context) { gap(context, `${name}整柜前置`, `缺少本轮${name}专用fixture,不能验证整批零漂移`, 'P1') } } - if (!value.pdaToken) gap(context, 'PDA候选Token前置', '缺少绑定真实海外仓的WAREHOUSE_PICKER海外Token') return value } @@ -63,32 +62,33 @@ async function snapshot(context, source) { } function convertBody(source) { - const anchor = source.orders[0] - return { orderId: id(anchor.orderId), customerId: id(anchor.customerId) } + return { sourceContainerBookingId: id(source.sourceContainerBookingId) } } async function convert(context, name, source, key) { const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT, - convertBody(source), {}, context.overseasToken, { 'Idempotency-Key': key }) + convertBody(source), { 'Idempotency-Key': key }, context.overseasToken) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return response } export async function run(context) { const data = fixture(context) + const summaryResponse = await context.requestOverseas('GET', + `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) + context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse, + success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, { + sourceContainerBookingId: data.normal.sourceContainerBookingId, + containerNo: data.normal.containerNo, + orderCount: CONTAINER_CONVERT_EXPECTED_ORDERS, + inboundCodeCount: CONTAINER_CONVERT_EXPECTED_ORDERS, + }), `orderPageSize=10, expectedOrderCount=15; ${trace(summaryResponse)}`, OPTIONS) const normalList = await listOrders(context, data.normal.containerNo) - context.assert(MODULE, 'P0同柜15票列表前置', normalList.response, + context.assert(MODULE, 'P1同柜15票列表前置', normalList.response, success(normalList.response) && normalList.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS && normalList.rows.every((row) => typeof row.id === 'string' && typeof row.customerId === 'string'), `orders=${normalList.rows.length}; ${trace(normalList.response)}`, OPTIONS) - for (const expected of data.normal.orders) { - const actual = normalList.rows.find((row) => id(row.id) === id(expected.orderId)) - context.assert(MODULE, `P2订单事实不猜算:${id(expected.orderId)}`, normalList.response, - Boolean(actual) && validOrderFact(actual, expected), - `customerId=${id(expected.customerId)}, inboundCode=${expected.inboundCode || 'missing'}`, { ...OPTIONS, deploymentBlocker: false }) - } - const invalidBefore = await snapshot(context, data.invalid) const invalid = await convert(context, 'P1任一非法票整柜转换拒绝', data.invalid, `${context.marker}-invalid-container`) const invalidAfter = await snapshot(context, data.invalid) @@ -103,9 +103,9 @@ export async function run(context) { rejected(partial, ['A0409']) && zeroDrift(partialBefore, partialAfter), trace(partial), OPTIONS) const key = `${context.marker}-container-convert` - const first = await convert(context, 'P0整柜一次转换并逐订单生成独立计划', data.normal, key) + const first = await convert(context, 'P1整柜一次转换并逐订单生成独立计划', data.normal, key) const result = first?.body?.data - context.assert(MODULE, 'P0整柜响应覆盖全部订单和客户计划', first, + context.assert(MODULE, 'P1整柜响应覆盖全部订单和独立计划', first, success(first) && validContainerBatchResult(result, data.normal.orders), trace(first), OPTIONS) const replay = await convert(context, 'P1同键同请求重放', data.normal, key) @@ -122,30 +122,9 @@ export async function run(context) { concurrent.map(trace).join('; '), OPTIONS) const after = await listOrders(context, data.normal.containerNo) - context.assert(MODULE, 'P0全部15票推进INBOUND_PLANNED', after.response, + context.assert(MODULE, 'P1全部15票推进INBOUND_PLANNED', after.response, success(after.response) && after.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS && after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo), `planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS) - const candidateResponse = await context.requestOverseas('GET', - '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, data.pdaToken) - const candidate = Array.isArray(candidateResponse?.body?.data) - ? candidateResponse.body.data.find((row) => row?.containerNo === data.normal.containerNo) : null - context.assert(MODULE, 'P0同柜PDA候选计划数与入仓码数一致', candidateResponse, - success(candidateResponse) && validPdaCandidate(candidate, { - containerNo: data.normal.containerNo, - planCount: data.normal.orders.length, - inboundCodeCount: data.normal.orders.length, - }), trace(candidateResponse), OPTIONS) - - const carton = data.normal.orders.flatMap((order) => order.cartonMarks || []) - .map((cartonMark) => ({ cartonMark })).at(0) - if (!carton) gap(context, 'P3箱码命中fixture', '正常整柜没有可用于resolve的冻结箱码', 'P3') - const prepared = data.preparedSession - if (!prepared) gap(context, 'P3箱码命中会话', '需先由PDA合同完成prepare+三照片+sign,再执行resolve', 'P3') - const resolved = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', carton, {}, data.pdaToken) - context.assert(MODULE, 'P3箱码服务端命中当前柜唯一入仓码', resolved, - success(resolved) && data.normal.orders.some((order) => order.inboundCode === resolved?.body?.data?.inboundCode), - trace(resolved), { ...OPTIONS, deploymentBlocker: false }) } diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 783bdbab3..eb6c1a80c 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -5,12 +5,11 @@ import test from 'node:test' import { CONTAINER_CONVERT_ENDPOINT, CONTAINER_CONVERT_EXPECTED_ORDERS, - distinctPhysicalPalletCount, + CONTAINER_SUMMARY_ENDPOINT, groupOrdersByCustomer, stableReplay, validContainerBatchResult, - validOrderFact, - validPdaCandidate, + validContainerSummaryPage, zeroDrift, } from './container-inbound-plan-batch-contract.mjs' @@ -19,7 +18,6 @@ const scenario = read('phase4-container-inbound-plan-batch.mjs') const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java') const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java') const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') -const pda = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java') const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') @@ -36,35 +34,32 @@ test('semantic contract creates one independent plan per order even for the same const source = orders() assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15) assert.equal(groupOrdersByCustomer(source).size, 2) - const result = { plans: source.map((order, index) => ({ + const result = source.map((order, index) => ({ customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT', lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}], - })) } + })) assert.equal(validContainerBatchResult(result, source), true) }) -test('facts preserve source values and physical pallets are not counted once per SKU', () => { - const expected = orders()[0] - assert.equal(validOrderFact({ ...expected }, expected), true) - assert.equal(validOrderFact({ ...expected, actualArrivalAt: '2026-09-02T08:00:00' }, expected), false) - assert.equal(distinctPhysicalPalletCount([ - { physicalPalletId: 'P1', sku: 'A' }, { physicalPalletId: 'P1', sku: 'B' }, { physicalPalletId: 'P2', sku: 'A' }, - ]), 2) +test('server container summary remains complete when the order page would contain only ten rows', () => { + assert.equal(CONTAINER_SUMMARY_ENDPOINT, '/api/platform/v1/last-leg/orders/container-summaries') + assert.equal(validContainerSummaryPage({ + records: [{ sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: '15', inboundCodeCount: '15' }], + page: '1', size: '10', total: '1', pages: '1', + }, { sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: 15, inboundCodeCount: 15 }), true) }) -test('atomic failure, replay and PDA count helpers reject drift', () => { +test('atomic failure and replay helpers reject drift', () => { const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] } assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true) assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true) - assert.equal(validPdaCandidate({ containerNo: 'C1', planCount: '2', inboundCodeCount: '15' }, - { containerNo: 'C1', planCount: 2, inboundCodeCount: 15 }), true) }) test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => { - assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert') - for (const marker of ['P0整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', + assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert-container') + for (const marker of ['P1整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', 'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突', - 'P0同柜PDA候选计划数与入仓码数一致', 'P3箱码服务端命中当前柜唯一入仓码']) { + 'P1服务端柜级摘要跨订单分页仍返回15票']) { assert.match(scenario, new RegExp(marker)) } assert.match(scenario, /Promise\.all/) @@ -73,17 +68,14 @@ test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PD assert.match(scenario, /traceId/) }) -test('platform API keeps two long string identifiers and Admin uses the selected row customer', () => { +test('container API uses one stable long string identity', () => { const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`), openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`))) - assert.match(endpoint, /required: \[customerId, orderId\]/) - assert.match(endpoint, /customerId:\s*\{ type: string/) - assert.match(endpoint, /orderId:\s*\{ type: string/) - assert.match(controller, /convertScope\(user, request\.customerId\(\)\)/) - assert.match(adminApi, /orderId: string[\s\S]{0,80}customerId: string/) - assert.match(adminView, /selectedRows\.value\.length !== 1/) - assert.match(adminView, /customerId = typeof selected\.customerId === 'string' \? selected\.customerId\.trim\(\)/) - assert.match(adminView, /orderId: String\(selected\.id\), customerId/) + assert.match(endpoint, /required: \[sourceContainerBookingId\]/) + assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/) + assert.match(controller, /convertContainer/) + assert.match(adminApi, /sourceContainerBookingId: string/) + assert.match(adminView, /sourceContainerBookingId: row\.sourceContainerBookingId/) }) test('backend batch conversion is one transaction and never merges same-customer orders', () => { @@ -92,13 +84,14 @@ test('backend batch conversion is one transaction and never merges same-customer assert.match(service, /(?:List|Container.*Response|Batch.*Response)/) assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/) assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/) - assert.match(service, /(?:partial|部分已转换|already belongs)[\s\S]{0,180}(?:CONFLICT|HttpStatus\.CONFLICT)/i) + assert.match(service, /来源柜存在不能转换的尾程订单/) + assert.match(service, /HttpStatus\.CONFLICT/) }) -test('PDA candidate and resolve contracts remain server-owned', () => { - assert.match(pda, /grouped\.computeIfAbsent\(plan\.getContainerNo\(\)/) - assert.match(pda, /entry\.getValue\(\)\.size\(\)/) - assert.match(pda, /totals\.inboundCount\(\)/) - assert.match(pda, /requireReceivingAvailable\(customerId, containerNo\)/) - assert.match(pda, /requireCartonRef|requireUnmatchedFbaCartonPrefix/) +test('Admin consumes a server-paged container summary and never derives 15 from the current order page', () => { + assert.match(adminApi, /(?:listLastMileContainerSummaries|listContainerSummaries)/) + assert.match(adminApi, /container-summaries/) + assert.match(adminView, /(?:listLastMileContainerSummaries|listContainerSummaries)\(/) + assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) }) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index eeb4bcdaa..00819578d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -232,7 +232,9 @@ Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin $domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') $domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') $domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') -Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/gateway-api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite "'/gateway-api'" '国内 PDA H5 独立代理前缀' +Assert-Contains $domesticPdaVite 'path.replace(/^\/gateway-api/, ''/api'')' '国内 PDA H5 Gateway 重写' Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' @@ -303,6 +305,21 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" ' Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览' Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界' +# 待分配尾程订单按来源柜由服务端聚合,整柜转换每票独立计划且整批原子。 +$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约' +Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' +Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' +Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' +Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' +Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' +Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' + +# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。 +Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同' +Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA 库位展示合同' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -312,6 +329,8 @@ foreach ($module in $pom.project.modules.module) { # 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。 $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue', 'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/pallets.ts', @@ -351,6 +370,8 @@ $criticalPaths = @( 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/app-update.js', 'apps/pda-uniapp/tests/app-update.test.mjs', + 'apps/pda-uniapp/tests/partial-layout.test.mjs', + 'apps/pda-uniapp/tests/picking-location-display.test.mjs', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -423,6 +444,9 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java', + 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java', + 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', From 600b2a2dc4aa980ec176aac38be9c28e389a89ba Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:31:19 +0800 Subject: [PATCH 12/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loading/repository/LoadingRepository.java | 11 +++++++++++ .../service/LoadingDeliveryService.java | 13 +++++++++++++ .../service/LoadingDeliveryServiceTest.java | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 221f62e3d..9ffd2e52b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -61,6 +61,17 @@ public class LoadingRepository { .eq(LoadingEvidenceEntity::getSealNo, sealNo)); } + public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId, + String evidenceType, long fileId) { + return evidences.selectOne(new LambdaQueryWrapper() + .eq(LoadingEvidenceEntity::getTenantId, tenantId) + .eq(LoadingEvidenceEntity::getInboundId, inboundId) + .eq(LoadingEvidenceEntity::getEvidenceType, evidenceType) + .eq(LoadingEvidenceEntity::getFileId, fileId) + .eq(LoadingEvidenceEntity::getStatus, "ACTIVE") + .last("LIMIT 1")); + } + public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); } public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 52467a049..8e97acbdc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -34,6 +34,7 @@ import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -74,6 +75,18 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务"); } } + LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile( + tenantId, inboundId, request.evidenceType(), request.fileId()); + if (existingFile != null) { + String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) + ? request.sealNo().trim() : null; + if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) { + throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄"); + } + LoadingActionResponse result = new LoadingActionResponse( + requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId)); + return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result); + } var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType()); String fileName = file.fileName(); String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 7fdc885e3..c2e32e59b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -350,6 +350,24 @@ class LoadingDeliveryServiceTest { "PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus()))); } + @Test + void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); + LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001"); + existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE"); + when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing); + when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing); + when(repository.listEvidence(10, 20)).thenReturn(List.of(existing)); + + LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo", + new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null)); + + assertThat(result.evidences().evidences()).hasSize(1); + verify(repository, never()).createEvidence(any()); + verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong()); + } + @Test void voidsPhotoWithReasonAndKeepsTheRecord() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); From c226b6a99b4714a16f66245c60482cdbfd2a4f0a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:49:02 +0800 Subject: [PATCH 13/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LoadingDeliveryService.java | 48 +++++++++++++---- .../service/LoadingDeliveryServiceTest.java | 52 ++++++++++++++++++- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 8e97acbdc..aa1795907 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -116,13 +116,23 @@ public class LoadingDeliveryService { private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request) { + return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, + key, request, false); + } + + private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, + long operatorId, String operatorName, String key, + CompleteLoadingRequest request, + boolean acceptedPdaCompletion) { String hash = hash(request); - LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); + LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, + key, hash, acceptedPdaCompletion); if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId); if (target != null) { - return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target); + return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, + key, request, target, acceptedPdaCompletion); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { loadingModelBridge.requireCompleted(tenantId, inboundId); @@ -132,7 +142,7 @@ public class LoadingDeliveryService { if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); loadingModelBridge.requireCompleted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); - requireCompletedPicking(tenantId, inboundId); + requirePickingGate(tenantId, inboundId, acceptedPdaCompletion); requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); @@ -145,12 +155,14 @@ public class LoadingDeliveryService { * Resolve and lock the canonical completion row after settlement, then retain optimistic locking * with that current server version instead of reusing the pre-settlement task snapshot. */ + @Transactional public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long operatorId, String operatorName, String key, String remark) { return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark); } + @Transactional public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long evidenceInboundId, long operatorId, String operatorName, String key, String remark) { @@ -161,7 +173,7 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); } return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key, - new CompleteLoadingRequest(current.version(), remark)); + new CompleteLoadingRequest(current.version(), remark), true); } /** @@ -185,7 +197,8 @@ public class LoadingDeliveryService { private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request, - CompletionTarget target) { + CompletionTarget target, + boolean acceptedPdaCompletion) { if ("LOADED".equals(target.loadedChildStatus())) { return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key, new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId))); @@ -195,7 +208,7 @@ public class LoadingDeliveryService { } loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); - requireCompletedPicking(tenantId, target.sourceInboundId()); + requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -308,9 +321,12 @@ public class LoadingDeliveryService { task.getExpectedCount(), task.getPickedCount(), task.getRemark()); } - private void requireCompletedPicking(long tenantId, long inboundId) { - if (!canonicalPicking(tenantId, inboundId).completed()) { - throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成"); + /** completion worker 已持有 durable lease;此时 task 尚为 COMPLETING,不能循环要求最终 COMPLETED。 */ + private void requirePickingGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) { + CanonicalPicking picking = canonicalPicking(tenantId, inboundId); + if (acceptedPdaCompletion ? !picking.resolved() : !picking.completed()) { + throw new BizException(ResultCode.CONFLICT, acceptedPdaCompletion + ? "PDA实装明细尚未全部解决" : "PDA拣货复核尚未完成"); } } @@ -337,9 +353,12 @@ public class LoadingDeliveryService { private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount, int completedTaskCount, int unresolvedCount) { - boolean completed() { + boolean resolved() { return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0 - && completedTaskCount == taskCount && unresolvedCount == 0; + && unresolvedCount == 0; + } + boolean completed() { + return resolved() && completedTaskCount == taskCount; } } @@ -431,10 +450,17 @@ public class LoadingDeliveryService { private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId, String operation, String key, String hash) { + return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false); + } + + private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId, + String operation, String key, String hash, + boolean resumeProcessing) { validateKey(key); if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null; LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key); if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); + if (resumeProcessing && "PROCESSING".equals(existing.getStatus())) return null; if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试"); try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); } catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index c2e32e59b..1d140af65 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -25,6 +25,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -197,6 +198,43 @@ class LoadingDeliveryServiceTest { verify(repository, never()).completeLoadedChild(10, 20, 7, 6); } + @Test + void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() { + readyForPdaFinalizeFacts(); + CompletionTarget settled = new CompletionTarget(20L, null, 20L, + "LOADING", 7, "LOADING", 7); + when(repository.completionTarget(10, 20)).thenReturn(settled); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7)); + when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1); + AtomicReference hash = new AtomicReference<>(); + when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), + eq("pda-resume"), anyString())).thenAnswer(invocation -> { + hash.set(invocation.getArgument(5)); + return false; + }); + when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume")) + .thenAnswer(invocation -> processingOperation(hash.get())); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-resume", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(repository).completeLoadedChild(10, 20, 7, 7); + verify(repository).completeOperation(eq(10L), eq(20L), eq(7L), + eq("COMPLETE_LOADING"), eq("pda-resume"), anyString()); + } + + @Test + void pdaFinalizeEntryPointsAreTransactional() throws Exception { + assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement", + long.class, long.class, long.class, String.class, String.class, String.class) + .getAnnotation(Transactional.class)).isNotNull(); + assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement", + long.class, long.class, long.class, long.class, String.class, String.class, String.class) + .getAnnotation(Transactional.class)).isNotNull(); + } + @Test void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() { readyForPdaFinalize(); @@ -436,11 +474,16 @@ class LoadingDeliveryServiceTest { private void readyForPdaFinalize() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) .thenReturn(true); + readyForPdaFinalizeFacts(); + } + + private void readyForPdaFinalizeFacts() { LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); when(progress.getExpectedCount()).thenReturn(5); when(progress.getPickedCount()).thenReturn(5); when(progress.getTaskCount()).thenReturn(1); - when(progress.getCompletedTaskCount()).thenReturn(1); + // completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet. + when(progress.getCompletedTaskCount()).thenReturn(0); when(progress.getUnresolvedCount()).thenReturn(0); when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); when(repository.listEvidence(10, 20)).thenReturn(List.of( @@ -462,4 +505,11 @@ class LoadingDeliveryServiceTest { value.setResultJson(resultJson); return value; } + + private static LoadingOperationEntity processingOperation(String requestHash) { + LoadingOperationEntity value = new LoadingOperationEntity(); + value.setRequestHash(requestHash); + value.setStatus("PROCESSING"); + return value; + } } From f5f72d681ddf21475fa12642cee9fdd1de777d9f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 18:05:02 +0800 Subject: [PATCH 14/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LoadingDeliveryService.java | 15 +++++++++--- .../service/LoadingDeliveryServiceTest.java | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index aa1795907..43321e6da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -135,12 +135,12 @@ public class LoadingDeliveryService { key, request, target, acceptedPdaCompletion); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { - loadingModelBridge.requireCompleted(tenantId, inboundId); + requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId))); } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); - loadingModelBridge.requireCompleted(tenantId, inboundId); + requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); completionGate.requireReleasedBooking(tenantId, inboundId); requirePickingGate(tenantId, inboundId, acceptedPdaCompletion); requireCompletionEvidence(tenantId, evidenceInboundId); @@ -206,7 +206,7 @@ public class LoadingDeliveryService { if (!"LOADING".equals(target.loadedChildStatus())) { throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); } - loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); + requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); requireCompletionEvidence(tenantId, evidenceInboundId); @@ -321,6 +321,15 @@ public class LoadingDeliveryService { task.getExpectedCount(), task.getPickedCount(), task.getRemark()); } + /** worker 先完成各入库单,之后才完成柜次计划;这里不能循环要求计划已经 COMPLETED。 */ + private void requireLoadingPlanGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) { + if (acceptedPdaCompletion) { + loadingModelBridge.requireStarted(tenantId, inboundId); + return; + } + loadingModelBridge.requireCompleted(tenantId, inboundId); + } + /** completion worker 已持有 durable lease;此时 task 尚为 COMPLETING,不能循环要求最终 COMPLETED。 */ private void requirePickingGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) { CanonicalPicking picking = canonicalPicking(tenantId, inboundId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 1d140af65..f4e7fe2a1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -196,6 +196,8 @@ class LoadingDeliveryServiceTest { verify(repository).findInboundForUpdate(10, 20); verify(repository).completeLoadedChild(10, 20, 7, 7); verify(repository, never()).completeLoadedChild(10, 20, 7, 6); + verify(loadingModelBridge).requireStarted(10, 20); + verify(loadingModelBridge, never()).requireCompleted(10, 20); } @Test @@ -223,6 +225,26 @@ class LoadingDeliveryServiceTest { verify(repository).completeLoadedChild(10, 20, 7, 7); verify(repository).completeOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), eq("pda-resume"), anyString()); + verify(loadingModelBridge).requireStarted(10, 20); + verify(loadingModelBridge, never()).requireCompleted(10, 20); + } + + @Test + void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) + .thenReturn(true); + when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8)); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8)); + when(repository.listEvidence(10, 20)).thenReturn(List.of()); + + LoadingActionResponse result = service.completeAfterPdaSettlement( + 10, 20, 7, "admin", "pda-already-loaded", "完成"); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(loadingModelBridge).requireStarted(10, 20); + verify(loadingModelBridge, never()).requireCompleted(10, 20); + verify(repository).completeOperation(eq(10L), eq(20L), eq(7L), + eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString()); } @Test @@ -251,6 +273,8 @@ class LoadingDeliveryServiceTest { assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(repository).findInboundForUpdate(10, 21); verify(repository).completeLoadedChild(10, 20, 7, 2); + verify(loadingModelBridge).requireStarted(10, 20); + verify(loadingModelBridge, never()).requireCompleted(10, 20); } @Test From 5250f304be7d29f3adc8228aefa07e59f3f97b08 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 18:11:57 +0800 Subject: [PATCH 15/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java | 2 +- .../firstleg/repository/LoadingPickItemMapperContractTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index a39cba50e..e6e12e61a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -232,6 +232,7 @@ public interface LoadingPickItemMapper extends BaseMapper /** * 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。 * PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。 + * 异步完成期间任务会在 COMPLETING/COMPLETION_FAILED 间切换,不能把任务状态当成明细未完成事实。 */ @Select(""" SELECT COUNT(*) @@ -244,7 +245,6 @@ public interface LoadingPickItemMapper extends BaseMapper JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id WHERE pick_item.loading_plan_item_id = plan_item.id AND pick_task.loading_plan_id = #{planId} - AND pick_task.task_status = 'LOADING' AND ( (pick_item.item_status = 'PICKED' AND pick_item.picked_package_count = plan_item.package_count) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 6a91c556b..06477ebdf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest { .contains("plan_item.loading_plan_id = #{planId}") .contains("plan_item.deleted = 0") .contains("pick_task.loading_plan_id = #{planId}") - .contains("pick_task.task_status = 'LOADING'") + .doesNotContain("pick_task.task_status") .contains("pick_item.item_status = 'PICKED'") .contains("pick_item.picked_package_count = plan_item.package_count") .contains("pick_item.item_status = 'PARTIAL'") From c7061b8a74e7ae0c831750fe2836b0f6befabc54 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 18:18:14 +0800 Subject: [PATCH 16/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 - .../main/resources/db/phase3-smart-loading-schema.sql | 1 - .../firstleg/repository/SmartLoadingSqlContractTest.java | 9 +++++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 38bf980aa..d1abd3ecd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5618,7 +5618,6 @@ BEGIN JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id WHERE pick_item.loading_plan_item_id = item.id AND pick_task.loading_plan_id = OLD.id - AND pick_task.task_status = 'LOADING' AND ( (pick_item.item_status = 'PICKED' AND pick_item.picked_package_count = item.package_count) diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 9fcc58c13..96979710b 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -862,7 +862,6 @@ BEGIN JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id WHERE pick_item.loading_plan_item_id = item.id AND pick_task.loading_plan_id = OLD.id - AND pick_task.task_status = 'LOADING' AND ( (pick_item.item_status = 'PICKED' AND pick_item.picked_package_count = item.package_count) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index c7cb7aa7b..ce8e6c27e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest { } private static void assertLoadingCompletionTrigger(String sql) { - assertThat(sql) + int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update"); + int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart); + assertThat(triggerStart).isGreaterThanOrEqualTo(0); + assertThat(triggerEnd).isGreaterThan(triggerStart); + String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd); + assertThat(beforeUpdateTrigger) .contains("AND NOT EXISTS (") .contains("pick_task.loading_plan_id = OLD.id") - .contains("pick_task.task_status = 'LOADING'") + .doesNotContain("pick_task.task_status") .contains("pick_item.item_status = 'PICKED'") .contains("pick_item.item_status = 'PARTIAL'") .contains("pick_item.picked_package_count > 0") From 2b7f66cb042683fa7d698a9e2259518249354c12 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 19:14:54 +0800 Subject: [PATCH 17/19] fix: reclaim newly added container plans --- .../inboundplan/pda/service/ReceivingContainerService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index ad9ca9d54..632eb6417 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -150,6 +150,9 @@ public class ReceivingContainerService { requireSessionWarehouse(active, overseasWarehouseId); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); save(active, "PREPARE", request.idempotencyKey(), command, result); return result; } @@ -727,8 +730,7 @@ public class ReceivingContainerService { private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) .filter(Objects::nonNull).collect(Collectors.toSet()); - if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() - .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + if (assigned.size() > 1) { throw conflict("Container plans have inconsistent receiving warehouse assignments"); } return assigned.isEmpty() || assigned.contains(overseasWarehouseId); From ec8f84c1c0b80a9164779f55cd22fa3754646acc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 19:27:59 +0800 Subject: [PATCH 18/19] fix: reject missing receiving pallet --- .../inboundplan/pda/service/ReceivingContainerService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 632eb6417..57352e657 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -257,6 +257,7 @@ public class ReceivingContainerService { String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + if (pallet == null) throw notFound("Pallet was not found"); validatePallet(ref, pallet); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); From e72ac14425a8af7b454eeb19be8cca930c6b165c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 19:31:38 +0800 Subject: [PATCH 19/19] fix: map warehouse pallet lookup failure --- .../inboundplan/pda/service/ReceivingContainerService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 57352e657..195326cd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -256,7 +256,12 @@ public class ReceivingContainerService { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); - WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + WarehouseResourceGateway.WarehousePalletReference pallet; + try { + pallet = warehouse.resolvePallet(request.palletCode()); + } catch (IllegalArgumentException exception) { + throw notFound("Pallet was not found"); + } if (pallet == null) throw notFound("Pallet was not found"); validatePallet(ref, pallet); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),