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] =?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 //