fix: unify receiving discrepancy confirmation
This commit is contained in:
+19
@@ -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);
|
||||
}
|
||||
|
||||
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
|
||||
|
||||
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+266
@@ -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;
|
||||
}
|
||||
}
|
||||
+197
-2
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user