fix: unify receiving discrepancy confirmation

This commit is contained in:
2026-11-06 20:08:45 +08:00
parent 1611c42d28
commit 87bb905283
12 changed files with 1521 additions and 2 deletions
@@ -32,6 +32,7 @@ class InboundPlanControllerTest {
@Mock InboundFbaAppointmentService appointmentService;
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundReceivingReconciliationService receivingReconciliation;
private InboundPlanController controller;
@BeforeEach
@@ -41,6 +42,24 @@ class InboundPlanControllerTest {
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
ReflectionTestUtils.setField(controller, "receivingReconciliation", receivingReconciliation);
}
/** 平台统一核对短少或超收,客户范围、操作人与请求键原样交给事务服务。 */
@Test void platformConfirmsReceivingDifferenceWithExplicitScopeAndActor() {
var request = new com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.Confirm(
"100", "snapshot", "现场核对", List.of(), List.of());
controller.receivingReconciliation(platform(), "IP-1", "100");
verify(receivingReconciliation).snapshot("100", "IP-1");
controller.confirmReceivingReconciliation(platform(), "IP-1", "key", request);
verify(receivingReconciliation).confirm("100", "IP-1", "7", "key", request);
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.receivingReconciliation(merchant, "IP-1", "100"))
.isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(() -> controller.confirmReceivingReconciliation(merchant, "IP-1", "key", request))
.isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(() -> controller.receivingReconciliation(platform(), "IP-1", "0"))
.isInstanceOf(ResponseStatusException.class);
}
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
@@ -0,0 +1,152 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundReceivingReconciliationMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 使用真实 Mapper 校验整柜范围、历史完成会话和独立实盘取数。 */
class InboundReceivingReconciliationPersistenceTest {
/** 同来源柜次同仓跨客户共同核对,复用柜号、其他仓及不匹配来源均排除。 */
@Test
void scopeIncludesOtherCustomersOnlyWithinTheSameBookingContainerAndWarehouse() throws Exception {
var fixture = new Fixture();
assertThat(fixture.mapper.scopePlans("101", "IP-10", false))
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
assertThat(fixture.mapper.scopePlans("202", "IP-20", false))
.extracting(InboundPlan::getId).containsExactly(10L, 20L);
var lockedPlans = fixture.transaction.execute(status -> fixture.mapper.scopePlans("101", "IP-10", true));
assertThat(lockedPlans).extracting(InboundPlan::getId).containsExactly(10L, 20L);
}
/** 必须先定位指定客户的真实计划,空客户、其他客户和缺失柜次均不能扩大范围。 */
@Test
void scopeRequiresTheSelectedCustomerAndANonblankBooking() throws Exception {
var fixture = new Fixture();
assertThat(fixture.mapper.scopePlans(null, "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("", "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("202", "IP-10", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "MISSING", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-70", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-80", false)).isEmpty();
assertThat(fixture.mapper.scopePlans("101", "IP-90", false)).isEmpty();
}
/** 已完成会话释放活动键后,仍按柜次、柜号和仓定位,并能在事务中锁定。 */
@Test
void completedSessionCanBeLocatedAndLockedAfterItsActiveKeysAreCleared() throws Exception {
var fixture = new Fixture();
var originalTime = LocalDateTime.of(2026, 11, 6, 10, 0);
var latestTime = originalTime.plusHours(1);
fixture.session(100L, "BOOK-1", "CONT-1", 9L, "COMPLETED", originalTime);
fixture.session(110L, "BOOK-1", "CONT-1", 9L, "COMPLETED", latestTime);
fixture.session(111L, "BOOK-1", "CONT-1", 9L, "COMPLETED_WITH_DIFFERENCE", latestTime);
fixture.session(120L, "BOOK-2", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
fixture.session(130L, "BOOK-1", "CONT-1", 10L, "COMPLETED", latestTime.plusHours(1));
fixture.session(140L, "BOOK-1", "CONT-2", 9L, "COMPLETED", latestTime.plusHours(1));
fixture.session(150L, "book-1", "CONT-1", 9L, "COMPLETED", latestTime.plusHours(1));
ReceivingContainerSession session = fixture.transaction.execute(status ->
fixture.mapper.session("BOOK-1", "CONT-1", 9L, true));
assertThat(session).isNotNull();
assertThat(session.getId()).isEqualTo(111L);
assertThat(session.getStatus()).isEqualTo("COMPLETED_WITH_DIFFERENCE");
assertThat(session.getActiveOperatorKey()).isNull();
assertThat(session.getActiveContainerKey()).isNull();
assertThat(session.getCompletedAt()).isEqualTo(latestTime);
assertThat(fixture.mapper.session("BOOK-1", "CONT-1", 9L, false).getId()).isEqualTo(111L);
assertThat(fixture.mapper.session("MISSING", "CONT-1", 9L, false)).isNull();
assertThat(fixture.mapper.session(null, "CONT-1", 9L, false)).isNull();
}
/** 独立实盘保留超申报的完整数量及明确零记录,其他会话同码托盘不能混入。 */
@Test
void physicalCountsRetainActualAndExplicitZeroWithinTheirOriginalSession() throws Exception {
var fixture = new Fixture();
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (2,111,'FBA:CONT-1:ONT8','PAL-2',0)");
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (1,111,'FBA:CONT-1:ONT8','PAL-1',22)");
fixture.db.update("INSERT INTO ll_receiving_fba_pallet_count VALUES (3,222,'FBA:CONT-1:ONT8','PAL-1',66)");
var physical = fixture.mapper.physicalCounts(111L);
assertThat(physical).extracting(ReceivingFbaPalletCount::getId).containsExactly(1L, 2L);
assertThat(physical).extracting(ReceivingFbaPalletCount::getActualPackages).containsExactly(22, 0);
assertThat(physical).extracting(ReceivingFbaPalletCount::getSessionId).containsOnly(111L);
assertThat(fixture.mapper.physicalCounts(999L)).isEmpty();
}
private static final class Fixture {
final JdbcTemplate db;
final TransactionTemplate transaction;
final InboundReceivingReconciliationMapper mapper;
/** 仅建立本次生产 SQL 所需列,使用真实 MyBatis 和独立 READ_COMMITTED 事务。 */
Fixture() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:receiving-reconciliation-"
+ UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
db = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(128),"
+ "customer_id VARCHAR(128),source_order_id BIGINT,container_no VARCHAR(64),"
+ "overseas_warehouse_id BIGINT,status VARCHAR(32))");
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),"
+ "source_container_booking_id VARCHAR(128))");
db.execute("CREATE TABLE ll_receiving_container_session(id BIGINT PRIMARY KEY,"
+ "source_container_booking_id VARCHAR(128),container_no VARCHAR(64),"
+ "overseas_warehouse_id BIGINT,status VARCHAR(32),active_operator_key VARCHAR(255),"
+ "active_container_key VARCHAR(255),created_at TIMESTAMP,completed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_receiving_fba_pallet_count(id BIGINT PRIMARY KEY,session_id BIGINT,"
+ "group_key VARCHAR(255),pallet_code VARCHAR(128),actual_packages INT)");
plan(20L, "202", "BOOK-1", "CONT-1", 9L);
plan(10L, "101", "BOOK-1", "CONT-1", 9L);
plan(30L, "101", "BOOK-2", "CONT-1", 9L);
plan(40L, "101", "BOOK-1", "CONT-1", 10L);
plan(50L, "101", "BOOK-1", "CONT-2", 9L);
plan(60L, "101", "book-1", "CONT-1", 9L);
plan(70L, "101", "BOOK-1", "CONT-1", 9L);
db.update("UPDATE ll_order SET customer_id='202' WHERE id=1070");
plan(80L, "101", null, "CONT-1", 9L);
plan(90L, "101", " ", "CONT-1", 9L);
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(InboundReceivingReconciliationMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
factory.afterPropertiesSet();
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundReceivingReconciliationMapper.class);
}
/** 插入真实客户与来源订单的计划关系,测试只通过正式 Mapper 查询范围。 */
private void plan(long id, String customerId, String bookingId, String containerNo, long warehouseId) {
db.update("INSERT INTO ll_order VALUES (?,?,?)", id + 1000, customerId, bookingId);
db.update("INSERT INTO ll_inbound_plan VALUES (?,?,?,?,?,?,?)", id, "IP-" + id,
customerId, id + 1000, containerNo, warehouseId, "PENDING_RECONCILIATION");
}
/** 历史完成会话的活动唯一键为空,原始完成时间仍可作为核对证据。 */
private void session(long id, String bookingId, String containerNo, long warehouseId,
String status, LocalDateTime createdAt) {
db.update("INSERT INTO ll_receiving_container_session VALUES (?,?,?,?,?,?,?,?,?)",
id, bookingId, containerNo, warehouseId, status, null, null, createdAt, createdAt);
}
}
}
@@ -0,0 +1,266 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.dto.InboundReceivingReconciliation.*;
import com.baiyue.lastleg.inboundplan.entity.*;
import com.baiyue.lastleg.inboundplan.pda.entity.*;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku;
import com.baiyue.lastleg.inboundplan.pda.repository.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundReceivingReconciliationServiceTest {
@Mock InboundReceivingReconciliationRepository reconciliation;
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stock;
@Mock InboundDispatchReceiptGateway dispatch;
InboundReceivingReconciliationService service;
InboundPlan plan;
InboundPdaTask task;
InboundPlanLine line;
ReceivingContainerSession session;
List<InboundPlanPallet> pallets;
List<InboundPlanPalletAllocation> allocations;
final LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 11, 28);
/** 现场5加20箱,申报内仅分配5加16箱,另有一个明确待核对的预留托盘。 */
@BeforeEach void setup() {
service = new InboundReceivingReconciliationService();
ReflectionTestUtils.setField(service, "reconciliation", reconciliation);
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stock);
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
plan.setSourceOrderId(9L); plan.setContainerNo("BOX-1"); plan.setOverseasWarehouseId(99L);
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(5);
LastlegOrder order = new LastlegOrder(); order.setId(9L); order.setCustomerId("100");
order.setCustomerName("核对客户"); order.setSourceContainerBookingId("BOOKING-1");
order.setOrderType("FIRST_LEG"); order.setDestinationType("FBA");
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(receivedAt); task.setVersion(4);
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
line = new InboundPlanLine(); line.setId(3L); line.setPlanId(1L); line.setCustomerId("100");
line.setOrderId(9L); line.setInboundCode("IN-1"); line.setFbaWarehouseCode("ACY2"); line.setRequiredPackageCount(21);
session = new ReceivingContainerSession(); session.setId(50L); session.setVersion(4);
session.setSourceContainerBookingId("BOOKING-1"); session.setContainerNo("BOX-1");
session.setOverseasWarehouseId(99L); session.setStatus("COMPLETED_WITH_DIFFERENCE"); session.setCompletedAt(receivedAt);
pallets = new ArrayList<>(); allocations = new ArrayList<>();
for (int i = 0; i < 3; i++) {
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(10L+i); pallet.setPlanId(1L);
pallet.setCustomerId("100"); pallet.setPalletNo("PALLET-"+i); pallet.setActualPackageCount(i == 0 ? 5 : i == 1 ? 16 : 0);
pallet.setWarehousePalletId(100L+i); pallet.setWarehouseLocationId(200L+i); pallet.setWarehouseResourceLockId(300L+i);
pallet.setDestinationType("FBA"); pallet.setDestinationWarehouseCode("ACY2"); pallet.setVersion(1); pallets.add(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(20L+i);
allocation.setCustomerId("100"); allocation.setPlanId(1L); allocation.setLineId(3L); allocation.setPalletId(pallet.getId());
allocation.setPlannedPackageCount(7); allocation.setCountedPackageCount(pallet.getActualPackageCount());
allocation.setPickedPackageCount(pallet.getActualPackageCount()); allocation.setVersion(1); allocations.add(allocation);
}
lenient().when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan));
when(repository.order(9L)).thenReturn(order);
lenient().when(reconciliation.session(eq("BOOKING-1"), eq("BOX-1"), eq(99L), anyBoolean())).thenReturn(session);
lenient().when(repository.task("100",1L)).thenReturn(task);
lenient().when(repository.lockTask("100",1L)).thenReturn(task);
when(repository.lines(1L)).thenReturn(List.of(line)); when(repository.pallets(1L)).thenReturn(pallets);
when(repository.allocations(1L)).thenReturn(allocations);
for (int i=0; i<3; i++) {
lenient().when(repository.lockAllocation(1L,20L+i)).thenReturn(allocations.get(i));
lenient().when(repository.lockPallet(1L,10L+i)).thenReturn(pallets.get(i));
}
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",21)));
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-1",16)));
lenient().when(reconciliation.physicalCounts(50L)).thenReturn(List.of(physical("PALLET-0",5), physical("PALLET-1",20)));
}
/** 页面保留21箱归属,独立展示25箱实盘,唯一SKU预填完整实盘。 */
@Test void exposesPhysicalTwentyFiveSeparatelyFromAttributedTwentyOne() {
Snapshot snapshot = service.snapshot("100","IP-1");
assertTrue(snapshot.canConfirm()); assertEquals(21,snapshot.expectedPackageCount());
assertEquals(21,snapshot.attributedPackageCount()); assertEquals(25,snapshot.countedPackageCount());
assertEquals(List.of(5,20,0), snapshot.receipts().stream().map(Receipt::receivedPackageCount).toList());
verifyNoInteractions(stock,dispatch);
}
/** 超收按25箱入库,原申报21与原现场时间保留,同时交接运输并记录完整前后证据。 */
@Test void confirmsOverReceiptAndReplaysWithoutDuplicatingStock() {
Confirm request = request(service.snapshot("100","IP-1"), 5,20,0);
Result result = service.confirm("100","IP-1","admin","key",request);
assertEquals("COMPLETED",result.status()); assertEquals(25,result.confirmedPackageCount());
assertEquals(21,line.getRequiredPackageCount()); assertEquals(receivedAt,task.getCompletedAt());
assertEquals(20,allocations.get(1).getPickedPackageCount()); assertEquals(20,pallets.get(1).getActualPackageCount());
var sequence = inOrder(dispatch,reconciliation,stock);
sequence.verify(dispatch).lock(List.of(1L));
sequence.verify(reconciliation).session("BOOKING-1","BOX-1",99L,true);
sequence.verify(stock).stockInReconciled(eq("100"),eq(plan),eq(task),eq(List.of(line)),anyList(),anyList(),eq(receivedAt));
sequence.verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(audit.capture());
assertTrue(audit.getValue().getRequestJson().contains("sourceFacts"));
assertTrue(audit.getValue().getRequestJson().contains("现场核实数量"));
when(repository.operation("100","key")).thenReturn(audit.getValue());
assertEquals(result,service.confirm("100","IP-1","admin","key",request));
verify(stock,times(1)).stockInReconciled(anyString(),any(),any(),anyList(),anyList(),anyList(),any());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","other-admin","key",request));
}
/** 清点误录可在明确原因下更正为21箱,旧实盘完整留在审计中。 */
@Test void correctsPhysicalEntryAndCompletesAtTwentyOne() {
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),5,16,0));
assertEquals(21,result.confirmedPackageCount()); assertEquals("COMPLETED",result.status());
var saved=ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
verify(reconciliation,times(3)).savePhysical(saved.capture(),anyBoolean());
assertEquals(List.of(5,16,0),saved.getAllValues().stream().map(ReceivingFbaPalletCount::getActualPackages).toList());
}
/** 短少与全无实收都可确认,预留托盘明确写零,不伪造库存箱数。 */
@Test void confirmsShortageIncludingExplicitZero() {
Result result=service.confirm("100","IP-1","admin","key",request(service.snapshot("100","IP-1"),0,0,0));
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(0,result.confirmedPackageCount());
assertTrue(allocations.stream().allMatch(row->row.getCountedPackageCount()==0&&row.getPickedPackageCount()==0));
verify(reconciliation).replaceSkuReceipts("100",1L,List.of());
verify(dispatch).complete(eq(List.of(1L)),eq("admin"),any());
}
/** 每托归属必须与确认清点守恒,拒绝用21箱分配掩盖25箱实盘。 */
@Test void rejectsUnattributedOverReceiptBeforeAnyWrite() {
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
Confirm bad=new Confirm("100",snapshot.snapshotHash(),"现场核实数量",good.pallets(),request(snapshot,5,16,0).receipts());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",bad));
verifyNoInteractions(stock); verify(reconciliation,never()).savePhysical(any(),anyBoolean());
}
/** 旧快照不能覆盖另一操作的最新清点。 */
@Test void rejectsStaleSnapshot() {
Snapshot snapshot=service.snapshot("100","IP-1"); Confirm good=request(snapshot,5,20,0);
session.setVersion(5);
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",good));
verifyNoInteractions(stock); verify(reconciliation,never()).replaceSkuReceipts(anyString(),anyLong(),anyList());
}
/** 多SKU必须显式核对,不将总箱数自动猜分给商品。 */
@Test void explicitSkuShortageCannotBeOffsetByAnotherSkuOverReceipt() {
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(new FrozenSku("SKU-1",10),new FrozenSku("SKU-2",11)));
when(repository.skuReceipts("100",1L)).thenReturn(List.of(sku(10L,"SKU-1",5),sku(11L,"SKU-2",16)));
Snapshot snapshot=service.snapshot("100","IP-1");
assertEquals(21,snapshot.receipts().stream().mapToInt(Receipt::receivedPackageCount).sum());
List<Quantity> quantities=snapshot.receipts().stream().map(row -> new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),
row.palletId()==10L&&row.skuCode().equals("SKU-1")?5:
row.palletId()==11L&&row.skuCode().equals("SKU-1")?4:
row.palletId()==11L&&row.skuCode().equals("SKU-2")?16:0)).toList();
Confirm request=new Confirm("100",snapshot.snapshotHash(),"逐商品核实",request(snapshot,5,20,0).pallets(),quantities);
Result result=service.confirm("100","IP-1","admin","key",request);
assertEquals("PARTIAL_INBOUND",result.status()); assertEquals(25,result.confirmedPackageCount());
}
/** 非本人范围和缺失原会话不能进入确认。 */
@Test void rejectsForeignScopeAndMissingSession() {
assertThrows(ResponseStatusException.class,()->service.snapshot("200","IP-1"));
when(reconciliation.session(eq("BOOKING-1"),eq("BOX-1"),eq(99L),anyBoolean())).thenReturn(null);
Snapshot snapshot=service.snapshot("100","IP-1"); assertFalse(snapshot.canConfirm());
assertThrows(ResponseStatusException.class,()->service.confirm("100","IP-1","admin","key",request(snapshot,5,20,0)));
verifyNoInteractions(stock);
}
/** 原现场仍在收货时禁止后台抢先入库。 */
@Test void blocksActiveOnsiteSession() {
session.setStatus("RECEIVING"); assertFalse(service.snapshot("100","IP-1").canConfirm());
}
/** 已完成的普通地址票据从真实库存凭证恢复,不能因缺少批量收货行阻塞本柜核对。 */
@Test void completedOverseasOrderKeepsItsRealStockImmutable() {
addCompletedPlan(false);
Snapshot snapshot = service.snapshot("100", "IP-1");
Receipt fixed = snapshot.receipts().stream().filter(row -> row.planNo().equals("IP-2")).findFirst().orElseThrow();
assertEquals(8, fixed.receivedPackageCount());
assertFalse(fixed.editable());
Result result = service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0));
assertEquals("COMPLETED", result.status());
assertEquals(List.of("IP-1"), result.reconciledPlanNos());
verify(stock, never()).stockInReconciled(eq("200"), any(), any(), anyList(), anyList(), anyList(), any());
}
/** 同目的仓一件代发的原整托独立冻结,普通票不能把额外实收写入该托。 */
@Test void completedDropshipPalletCannotReceiveOrdinaryFbaCargo() {
addCompletedPlan(true);
Snapshot snapshot = service.snapshot("100", "IP-1");
Pallet dropship = snapshot.pallets().stream().filter(row -> row.palletId().equals(64L)).findFirst().orElseThrow();
assertTrue(dropship.groupKey().startsWith("DROPSHIP:"));
assertFalse(dropship.editable());
assertTrue(snapshot.receipts().stream().filter(row -> row.palletId().equals(64L))
.allMatch(row -> row.planNo().equals("IP-2") && !row.editable()));
assertEquals("COMPLETED", service.confirm("100", "IP-1", "admin", "key", request(snapshot, 5, 20, 0)).status());
}
/** 清点量变化时逐来源及整托体积同步采用现有单箱体积口径。 */
@Test void recalculatesActualVolumesFromConfirmedCounts() {
line.setVolumeCbm(new java.math.BigDecimal("2.1000"));
service.confirm("100", "IP-1", "admin", "key", request(service.snapshot("100", "IP-1"), 5, 20, 0));
assertEquals(new java.math.BigDecimal("2.0000"), allocations.get(1).getActualVolumeCbm());
assertEquals(new java.math.BigDecimal("2.0000"), pallets.get(1).getActualVolumeCbm());
assertEquals(new java.math.BigDecimal("0.0000"), pallets.get(2).getActualVolumeCbm());
}
/** 按三托单 SKU 样本生成完整确认请求,其他只读票据采用原事实。 */
private Confirm request(Snapshot snapshot,int first,int second,int third) {
Map<Long,Integer> counts=Map.of(10L,first,11L,second,12L,third);
return new Confirm("100",snapshot.snapshotHash(),"现场核实数量",
snapshot.pallets().stream().map(row->new Count(row.palletId(),counts.getOrDefault(row.palletId(), row.countedPackageCount()))).toList(),
snapshot.receipts().stream().map(row->new Quantity(row.customerId(),row.planNo(),row.lineId(),row.palletId(),row.skuCode(),counts.getOrDefault(row.palletId(), row.receivedPackageCount()))).toList());
}
/** 准备同柜已结束的另一客户票据,其库存来源不可被本次普通实收改写。 */
private void addCompletedPlan(boolean dropship) {
InboundPlan other = new InboundPlan(); other.setId(60L); other.setPlanNo("IP-2"); other.setCustomerId("200");
other.setSourceOrderId(69L); other.setContainerNo("BOX-1"); other.setOverseasWarehouseId(99L);
other.setStatus("COMPLETED"); other.setVersion(2);
LastlegOrder order = new LastlegOrder(); order.setId(69L); order.setCustomerId("200");
order.setOrderType(dropship ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG");
order.setDestinationType(dropship ? "FBA" : "OVERSEAS");
InboundPdaTask completed = new InboundPdaTask(); completed.setId(62L); completed.setPlanId(60L);
completed.setCustomerId("200"); completed.setStatus("COMPLETED"); completed.setCompletedAt(receivedAt);
InboundPlanLine otherLine = new InboundPlanLine(); otherLine.setId(63L); otherLine.setPlanId(60L);
otherLine.setOrderId(69L); otherLine.setRequiredPackageCount(8); otherLine.setDestinationType(order.getDestinationType());
otherLine.setDestinationWarehouseId("DEST-2"); otherLine.setFbaWarehouseCode(dropship ? "ACY2" : null);
InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(64L); pallet.setPlanId(60L);
pallet.setCustomerId("200"); pallet.setPalletNo("OTHER-PALLET"); pallet.setActualPackageCount(8);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(65L);
allocation.setPlanId(60L); allocation.setLineId(63L); allocation.setPalletId(64L);
allocation.setCountedPackageCount(8); allocation.setPickedPackageCount(8);
InboundStockReceipt stock = new InboundStockReceipt(); stock.setAllocationId(65L); stock.setLineId(63L);
stock.setSkuCode(dropship ? "MIXED" : "SKU-2"); stock.setReceivedPackages(8L);
when(reconciliation.plans(eq("100"), eq("IP-1"), anyBoolean())).thenReturn(List.of(plan, other));
when(repository.order(69L)).thenReturn(order);
when(repository.task("200", 60L)).thenReturn(completed);
when(repository.lockTask("200", 60L)).thenReturn(completed);
when(repository.lines(60L)).thenReturn(List.of(otherLine));
when(repository.pallets(60L)).thenReturn(List.of(pallet));
when(repository.allocations(60L)).thenReturn(List.of(allocation));
when(repository.lockAllocation(60L, 65L)).thenReturn(allocation);
when(repository.lockPallet(60L, 64L)).thenReturn(pallet);
when(repository.frozenSkus("200", 60L, 63L)).thenReturn(List.of(new FrozenSku("SKU-2", 8)));
when(reconciliation.stockReceipts("200", 62L)).thenReturn(List.of(stock));
}
/** 构造已分配到货件的 SKU 数量。 */
private ReceivingSkuReceipt sku(long palletId,String skuCode,int packages) {
ReceivingSkuReceipt row=new ReceivingSkuReceipt(); row.setLineId(3L); row.setPalletId(palletId);
row.setSkuCode(skuCode); row.setReceivedPackages(packages); return row;
}
/** 构造独立于申报内分配的完整现场清点。 */
private ReceivingFbaPalletCount physical(String pallet,int packages) {
ReceivingFbaPalletCount row=new ReceivingFbaPalletCount(); row.setId(1000L+pallet.hashCode()); row.setSessionId(50L);
row.setGroupKey("FBA:BOX-1:ACY2"); row.setPalletCode(pallet); row.setActualPackages(packages);
row.setCreatedAt(receivedAt); row.setUpdatedAt(receivedAt); return row;
}
}
@@ -23,6 +23,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.storagerent.service.StorageRentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -61,18 +62,24 @@ class InboundStockReceiptServiceTest {
@Mock WarehouseResourceGateway warehouseGateway;
@Mock DropshipOrderService dropshipOrders;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock StorageRentService storageRent;
private InboundStockReceiptService service;
@BeforeEach
/** 组装库存、资源与逐箱事实的隔离依赖。 */
void setUp() {
service = new InboundStockReceiptService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
ReflectionTestUtils.setField(service, "storageRent", storageRent);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
var reconciliationRepository = new com.baiyue.lastleg.inboundplan.pda.repository.InboundReceivingReconciliationRepository();
ReflectionTestUtils.setField(reconciliationRepository, "orders", orders);
ReflectionTestUtils.setField(reconciliationRepository, "stockReceipts", receipts);
ReflectionTestUtils.setField(reconciliationRepository, "skus", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "reconciliationRepository", reconciliationRepository);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
@@ -82,7 +89,6 @@ class InboundStockReceiptServiceTest {
});
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of());
}
@@ -476,6 +482,195 @@ class InboundStockReceiptServiceTest {
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 明确核对的超收按5加20分别入库,保留21箱申报和原现场计租时间。 */
@Test void reconciledOverReceiptStocksAllConfirmedPallets() {
Fixture f = fixture(21, 21, 5, 210);
f.allocation.setCountedPackageCount(5);
var secondAllocation = allocation(32L, 12L, 22L, 20);
secondAllocation.setCountedPackageCount(20);
var secondPallet = pallet(22L, 2002L);
LocalDateTime receivedAt = LocalDateTime.of(2026, 11, 6, 12, 0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 5), skuReceipt(12, 22, "SKU-1", 20)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line),
List.of(f.allocation, secondAllocation), List.of(f.pallet, secondPallet), receivedAt);
var commands = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), commands.capture());
assertEquals(List.of(5L, 20L), commands.getAllValues().stream().map(InventoryCommandRequests.StockIn::packages).toList());
var saved = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts, times(2)).insert(saved.capture());
assertEquals(List.of(receivedAt, receivedAt), saved.getAllValues().stream().map(InboundStockReceipt::getReceivedAt).toList());
verify(storageRent, times(2)).registerInbound(any(InboundStockReceipt.class), eq(f.order));
assertEquals(21, f.line.getRequiredPackageCount());
assertEquals(21, f.detail.getPackageCount());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
}
/** 零实收可以完成异常核对,不创建库存或计租凭证。 */
@Test void reconciledZeroReceiptCompletesAsPartialWithoutStock() {
Fixture f = fixture(21, 21, 0, 210);
f.allocation.setCountedPackageCount(0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
List.of(f.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
verify(storageRent, never()).registerInbound(any(), any());
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
}
/** 一个 SKU 超收不能掩盖另一个 SKU 短少,即使全票总数多于申报。 */
@Test void reconciledKnownSkuOverReceiptStillPreservesOtherSkuShortage() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
f.detail.setPackageCount(10);
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110)));
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 16), skuReceipt(12, 21, "SKU-2", 9)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation),
List.of(f.pallet), LocalDateTime.now());
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
}
/** 多票分别判断短少,不能把另一票零实收的状态套给已核平的超收订单。 */
@Test void reconciledReceiptCompletesEachOrderFromItsOwnSkuFacts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
var emptyLine = new InboundPlanLine();
emptyLine.setId(13L); emptyLine.setOrderId(102L); emptyLine.setRequiredPackageCount(10);
var emptyOrder = new LastlegOrder();
emptyOrder.setId(102L); emptyOrder.setCustomerId("100"); emptyOrder.setCurrentStatus("INBOUND_PLANNED");
var emptyAllocation = allocation(32L, 13L, 22L, 0);
emptyAllocation.setCountedPackageCount(0);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order, emptyOrder));
when(orders.lockById("100", 102L)).thenReturn(emptyOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(f.detail), List.of(detail(202L, "SKU-2", 10, 100)));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 25, 0, 0, 0, false, false));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line, emptyLine),
List.of(f.allocation, emptyAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", emptyOrder.getSubOrderStatus());
verify(inventory).stockIn(eq("100"), eq("operator"), any());
}
/** 缺少明确清点事实不能按零实收入库。 */
@Test void reconciledReceiptRejectsUnknownZeroCount() {
Fixture f = fixture(21, 21, 0, 210);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 异常确认入口不能绕过一件代发必须整托收货的冻结来源。 */
@Test void reconciledReceiptRejectsDropshipOrder() {
Fixture f = fixture(21, 21, 0, 210);
f.allocation.setCountedPackageCount(0);
f.order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 未明确逐 SKU 实收时不按原申报顺序自动分摊超收。 */
@Test void reconciledReceiptRequiresExactSkuFacts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 异常核对仍不能引入冻结申报以外的 SKU。 */
@Test void reconciledReceiptRejectsUnknownSku() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "UNKNOWN", 25)));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 全票总数相等也须拒绝逐托实收与 SKU 实收错配。 */
@Test void reconciledReceiptRejectsMismatchedPalletQuantities() {
Fixture f = fixture(21, 21, 5, 210);
f.allocation.setCountedPackageCount(5);
var secondAllocation = allocation(32L, 12L, 22L, 20);
secondAllocation.setCountedPackageCount(20);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(
skuReceipt(12, 21, "SKU-1", 6), skuReceipt(12, 22, "SKU-1", 19)));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation, secondAllocation), List.of(f.pallet, pallet(22L, 2002L)), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
/** 同一份已入账实收可重放,不重复生成库存和计租。 */
@Test void reconciledReceiptReplaysOnlyMatchingStockReceipts() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
var existing = new InboundStockReceipt();
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(25L);
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
service.stockInReconciled("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
verify(storageRent, never()).registerInbound(any(), any());
assertEquals("INBOUND_PLAN:COMPLETED", f.order.getSubOrderStatus());
}
/** 已入账凭证数量变化必须拒绝,不能沿用仅比较 allocation ID 的普通重试分支。 */
@Test void reconciledReceiptRejectsChangedStockReceiptQuantity() {
Fixture f = fixture(21, 21, 25, 210);
f.allocation.setCountedPackageCount(25);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 25)));
var existing = new InboundStockReceipt();
existing.setAllocationId(31L); existing.setLineId(12L); existing.setSkuCode("SKU-1"); existing.setReceivedPackages(21L);
when(receipts.listTask("100", 2L)).thenReturn(List.of(existing));
assertThrows(ResponseStatusException.class, () -> service.stockInReconciled("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now()));
verify(inventory, never()).stockIn(any(), any(), any());
}
private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L);
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");