feat: complete finance and overseas receiving workflow

This commit is contained in:
2026-09-10 22:43:11 +08:00
parent e531ae9407
commit 56635ad4e4
66 changed files with 1601 additions and 417 deletions
@@ -38,8 +38,9 @@ class ReceivingContainerContractTest {
.map(RecordComponent::getName).collect(Collectors.toSet());
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
}
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Complete.class,
ReceivingContainerRequests.Sign.class, ReceivingContainerRequests.BulkReceive.class,
ReceivingContainerRequests.CartonReceive.class, ReceivingContainerRequests.WholePalletReceive.class)) {
RecordComponent key = Arrays.stream(type.getRecordComponents())
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
Size size = key.getAccessor().getAnnotation(Size.class);
@@ -67,7 +68,7 @@ class ReceivingContainerContractTest {
@Test
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
Transactional pda = ReceivingContainerService.class
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
.getMethod("complete", String.class, ReceivingContainerRequests.Complete.class)
.getAnnotation(Transactional.class);
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
.getAnnotation(Transactional.class);
@@ -103,6 +104,10 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(schema.contains("unique key uk_ll_receiving_fba_count(session_id,group_key,pallet_code)"));
assertTrue(tableBlock(schema, "ll_inbound_plan_pallet").contains("key idx_ll_inbound_pallet_no (pallet_no)"));
assertFalse(tableBlock(schema, "ll_inbound_plan_pallet").contains("unique key uk_ll_inbound_pallet_no"));
String order = tableBlock(schema, "ll_order");
assertTrue(order.contains("overseas_warehouse_id varchar(128) null"));
assertTrue(order.contains("overseas_warehouse_code varchar(128) null"));
@@ -113,6 +118,7 @@ class ReceivingContainerContractTest {
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("create table if not exists ll_receiving_fba_pallet_count"));
assertTrue(unified.contains("column_name='sign_photos'"));
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
@@ -120,7 +126,7 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("where status in ('completed','completed_with_difference') and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
@@ -30,6 +30,7 @@ import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -596,6 +597,24 @@ class InboundPdaWorkflowServiceTest {
any(), any(), anyLong());
}
@Test
void completionLoadsSharedFbaPalletOwnedByAnotherCustomerPlan() {
activeTask();
target.setPickedPackageCount(5);
InboundPlanPallet sharedPallet = pallet(40L, "WH-A", 5, "1.0000", 400L);
sharedPallet.setPlanId(99L);
target.setPalletId(sharedPallet.getId());
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.pallets(plan.getId())).thenReturn(List.of());
when(repository.palletsByIds(Set.of(sharedPallet.getId()))).thenReturn(List.of(sharedPallet));
service.complete("100", "LIP1");
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target)), eq(List.of(sharedPallet)), any());
}
@Test
void completedTaskReplayDoesNotRunStockInAgain() {
task.setStatus("COMPLETED");
@@ -16,11 +16,17 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
@@ -58,8 +64,11 @@ class ReceivingContainerServiceBehaviorTest {
@Mock LastlegOrderDetailMapper orderDetails;
@Mock ReceivingContainerSessionMapper sessions;
@Mock ReceivingContainerOperationMapper operations;
@Mock ReceivingFbaPalletCountMapper fbaPalletCounts;
@Mock ReceivingSkuReceiptMapper skuReceipts;
@Mock ContainerShippingGateMapper shippingGates;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock WarehouseResourceGateway warehouse;
@Mock UnloadPhotoFileGateway unloadPhotos;
@Mock InboundPdaWorkflowService workflow;
@@ -77,8 +86,11 @@ class ReceivingContainerServiceBehaviorTest {
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "sessions", sessions);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "fbaPalletCounts", fbaPalletCounts);
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
ReflectionTestUtils.setField(service, "workflow", workflow);
@@ -414,11 +426,10 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@@ -426,7 +437,7 @@ class ReceivingContainerServiceBehaviorTest {
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
@@ -442,6 +453,30 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void fbaOverReceiptAllocatesOnlyPlannedBalanceAndRecordsPhysicalDifference() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
when(fbaPalletCounts.sumActual(8L, "FBA:CONT-A:FBA-1")).thenReturn(3);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var result = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "over-mark-key"));
assertThat(result.result()).isEqualTo("DIFFERENCE_RECORDED");
assertThat(result.acceptedPackages()).isEqualTo(3);
verify(workflow).receiveBulkResolved(any(), any(), any(), any(),
org.mockito.ArgumentMatchers.eq(2), any(), any());
ArgumentCaptor<ReceivingFbaPalletCount> count = ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
verify(fbaPalletCounts).insert(count.capture());
assertThat(count.getValue().getActualPackages()).isEqualTo(3);
}
@Test
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A");
@@ -464,7 +499,7 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any());
}
@@ -480,7 +515,7 @@ class ReceivingContainerServiceBehaviorTest {
when(allocations.selectList(any())).thenReturn(List.of(received));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
var completed = service.complete("7", new ReceivingContainerRequests.Action("dropship-complete"));
var completed = service.complete("7", new ReceivingContainerRequests.Complete(false, "dropship-complete"));
assertThat(completed.status()).isEqualTo("COMPLETED");
verifyNoInteractions(skuReceipts);
@@ -491,11 +526,11 @@ class ReceivingContainerServiceBehaviorTest {
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setRequestHash(hash(new ReceivingContainerRequests.Complete(false, "complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
assertThat(service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@@ -504,8 +539,7 @@ class ReceivingContainerServiceBehaviorTest {
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
@@ -675,9 +709,10 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
void bulkRejectsInconsistentFrozenSkuProgressBeforeAnyReceiptWrite() {
stubCurrent("FBA", "CONT-A");
stubSku();
stubReservedFbaPallet();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
received.setReceivedPackages(2);
@@ -686,7 +721,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
@@ -707,7 +742,7 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
void fbaGroupResolutionKeepsUrgencyWithoutPerPlanMergeChoices() {
stubCurrent("FBA", "CONT-A");
InboundPlan flagged = plan("CONT-A");
flagged.setAssignedFlag(true);
@@ -719,9 +754,9 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.assigned()).isTrue();
assertThat(resolved.mergeAllowed()).isTrue();
assertThat(resolved.mergeAllowed()).isFalse();
assertThat(resolved.urgent()).isTrue();
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
assertThat(resolved.mergeHint()).isNull();
}
@Test
@@ -788,8 +823,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(2L)).thenReturn(secondOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
List.of(detail("SKU-C", 2)));
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -813,22 +847,27 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() {
void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanCarton frozen = carton(1L, "IN-1#2-1");
frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1");
when(cartons.selectOne(any())).thenReturn(frozen);
InboundPlanPallet pallet = planPallet(); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(30L); allocation.setPlanId(2L); allocation.setLineId(10L);
allocation.setPalletId(20L); allocation.setPlannedPackageCount(2); allocation.setPickedPackageCount(0);
when(dropshipCartons.sourcePalletsInContainer("CONT-A", "IN-1#2-1"))
.thenReturn(List.of("DP-260909-0001"));
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(allocation));
stubCompleteManifest("DP-260909-0001", 2);
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
assertThat(resolved.operationMode()).isEqualTo("DROPSHIP_PALLET");
assertThat(resolved.sourcePalletNo()).isEqualTo("DP-260909-0001");
assertThat(resolved.totalPackages()).isEqualTo(2);
verify(cartons, never()).insert(any(InboundPlanCarton.class));
verifyNoInteractions(warehouse);
when(cartons.selectOne(any())).thenReturn(null);
assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
verify(cartons, never()).insert(any(InboundPlanCarton.class));
}
@Test
@@ -837,7 +876,8 @@ class ReceivingContainerServiceBehaviorTest {
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setPalletNo("DP-260909-0001"); pallet.setSourcePalletNo("DP-260909-0001");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2);
@@ -847,6 +887,7 @@ class ReceivingContainerServiceBehaviorTest {
received.setPickedPackageCount(2);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received));
stubCompleteManifest("DP-260909-0001", 2);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -856,7 +897,7 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(result.acceptedPackages()).isEqualTo(2);
assertThat(result.receivedPackages()).isEqualTo(2);
assertThat(result.pendingPackages()).isZero();
assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1");
assertThat(result.palletCode()).isEqualTo("DP-260909-0001");
verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L),
any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class));
@@ -940,6 +981,28 @@ class ReceivingContainerServiceBehaviorTest {
return value;
}
private InboundPlanPallet stubReservedFbaPallet() {
InboundPlanPallet value = planPallet();
value.setPalletNo("PAL-1"); value.setWarehousePalletId(30L); value.setWarehouseLocationId(40L);
value.setWarehouseResourceLockId(50L); value.setAssignmentStatus("RESERVED"); value.setVersion(0);
when(pallets.selectList(any())).thenReturn(List.of(value));
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
30L, "PAL-1", 100L, 40L, "OCCUPIED", "FBA-1", 0));
return value;
}
private void stubCompleteManifest(String sourcePalletNo, int packages) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(60L); profile.setCustomerId("100"); profile.setSourcePalletNo(sourcePalletNo);
profile.setManifestVersion(1); profile.setManifestPackageCount(packages);
profile.setManifestFragmentCount(1); profile.setManifestHash("manifest-hash");
when(dropshipProfiles.listContainerPallet("CONT-A", sourcePalletNo)).thenReturn(List.of(profile));
List<DropshipCarton> values = new java.util.ArrayList<>();
for (int index = 0; index < packages; index++) values.add(new DropshipCarton());
when(dropshipCartons.listProfile("100", 60L)).thenReturn(values);
}
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);