fix(lastleg): confirm inbound shortages and reconcile empty preplanned pallets

This commit is contained in:
2026-10-26 15:46:13 +08:00
parent ba0bf9ebb7
commit e27881808d
24 changed files with 636 additions and 28 deletions
@@ -24,6 +24,74 @@ import static org.mockito.Mockito.*;
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
class DispatchInboundReceiptPersistenceTest {
/** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */
@Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception {
var f=new Fixture();
f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2");
f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21");
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2");
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
f.p.f.candidates.get(1L).packageCount(20);
f.p.f.candidates.get(2L).packageCount(0);
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number)));
f.confirm(20,0);
assertThat(f.quantities()).containsExactly(20);
assertThat(f.allocations()).containsExactly(20);
var row=f.p.f.repository.find(f.p.number);
assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1);
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED");
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED");
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE");
var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version);
var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null)));
assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue();
verify(f.p.inventory).ship(eq("101"),eq("picker"),any());
}
/** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */
@Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception {
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2");
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用");
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
}
/** 零候选但没有明确零实收证明,不能作为空托删除。 */
@Test void missingEmptyReceiptEvidenceStillRejects() throws Exception {
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0);
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘");
assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10);
}
/** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */
@Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception {
var f=new Fixture();
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9"));
f.confirm(0,0);
assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty();
var row=f.p.f.repository.find(f.p.number);
assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero();
assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT");
assertThat(row.getReviewBasisHash()).isNull();
}
/** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */
@Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception {
var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);
f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2");
doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9"));
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1");
assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源");
assertThat(f.quantities()).containsExactly(10,10);
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE");
assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE");
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
}
/** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */
@Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception {
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
@@ -53,6 +121,8 @@ class DispatchInboundReceiptPersistenceTest {
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
}
@@ -199,6 +269,7 @@ class DispatchInboundReceiptPersistenceTest {
doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L);
doReturn(true).when(p.sources).receivedPlanCompleted("101",2L);
for(long id=1;id<=2;id++) {
doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9"));
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id);
doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id);
doReturn(id).when(p.sources).lockPlan("101",id);
@@ -237,10 +308,15 @@ class DispatchInboundReceiptPersistenceTest {
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
void confirm() {
confirm(9,11);
}
/** 按指定真实数量验证短少和空托交接的同事务持久化。 */
void confirm(int first,int second) {
p.f.tx.executeWithoutResult(s->{
receipt.lock(List.of(1L,2L));
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END");
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END");
p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second);
p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second);
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
});
}
@@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest {
f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')");
var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class);
assertThat(mapper.receivedPlanCompleted("101",2L)).isZero();
for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) {
for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) {
f.f.db.update("UPDATE ll_inbound_plan SET status=?",status);
assertThat(mapper.receivedPlanCompleted("101",2L)).isOne();
assertThat(mapper.receivedPlanCompleted("other",2L)).isZero();
@@ -31,6 +31,7 @@ class InboundPlanControllerTest {
@Mock InboundPlanService service;
@Mock InboundFbaAppointmentService appointmentService;
@Mock ContainerInboundPlanConversionCoordinator containerConversion;
@Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow;
private InboundPlanController controller;
@BeforeEach
@@ -39,6 +40,18 @@ class InboundPlanControllerTest {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "appointmentService", appointmentService);
ReflectionTestUtils.setField(controller, "containerConversion", containerConversion);
ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow);
}
/** 短少确认只允许平台指定具体客户,并保留真实操作人。 */
@Test void platformConfirmsShortageWithExplicitCustomer() {
var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少");
controller.confirmShortage(platform(), "IP-1", "key", request);
verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request);
var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class);
var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少");
assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class);
}
@Test
@@ -0,0 +1,153 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest;
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.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundShortageConfirmationTest {
@Mock PdaWorkflowRepository repository;
@Mock InboundStockReceiptService stock;
@Mock InboundDispatchReceiptGateway dispatch;
@Mock InboundUnloadCompletedOutboxService outbox;
InboundPdaWorkflowService service;
InboundPlan plan;
InboundPdaTask task;
InboundPlanLine line;
InboundPlanPalletAllocation allocation;
final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0);
/** 准备已结束现场收货、应收21实收20的冻结事实。 */
@BeforeEach void setup() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "stockReceipts", stock);
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100");
plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4);
task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100");
task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt);
task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt);
line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21);
allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L);
allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20);
var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20);
when(repository.findPlan("100", "IP-1")).thenReturn(plan);
lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan);
lenient().when(repository.lockTask("100", 1L)).thenReturn(task);
lenient().when(repository.lines(1L)).thenReturn(List.of(line));
lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation));
lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku));
lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of(
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21)));
lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L);
}
/** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */
@Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() {
var request = request(4, 20);
var result = service.confirmShortage("100", "IP-1", "admin", "key", request);
assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount());
assertEquals(receivedAt, task.getCompletedAt());
var sequence = inOrder(dispatch, repository, stock);
sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1");
sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
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("现场确认短少1箱"));
when(repository.operation("100", "key")).thenReturn(audit.getValue());
assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request));
verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
verifyNoInteractions(outbox);
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request));
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
}
/** 页面旧版本或旧实收数不能确认库存。 */
@Test void rejectsStaleVersionAndCounts() {
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
verifyNoInteractions(stock);
}
/** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */
@Test void rejectsMissingSkuReceipts() {
when(repository.skuReceipts("100", 1L)).thenReturn(List.of());
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
verifyNoInteractions(stock);
}
/** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */
@Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() {
when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of(
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20),
new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1)));
var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
assertTrue(error.getReason().contains("单 SKU"));
verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any());
assertEquals("PENDING_RECONCILIATION",plan.getStatus());
}
/** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */
@Test void rejectsSharedFbaReceivingGroup() {
when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L);
assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20)));
verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class));
}
/** 客户归属不匹配时不触发任何库存或运输操作。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22})
void rejectsZeroCompleteAndExcessReceipts(int received) {
allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received);
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received)));
verifyNoInteractions(stock);
}
/** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */
@Test void rejectsMixedDestinations() {
var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS");
when(repository.lines(1L)).thenReturn(List.of(line, other));
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
verifyNoInteractions(stock);
}
/** 库存落账失败时不推进计划状态和运输交接。 */
@Test void doesNotAdvanceWhenStockInFails() {
doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true));
assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
assertEquals("PENDING_RECONCILIATION", plan.getStatus());
verify(dispatch, never()).complete(anyList(), anyString(), any());
verify(repository, never()).insert(any(InboundPdaOperation.class));
}
/** 客户归属不匹配时不触发任何库存或运输操作。 */
@Test void rejectsWrongCustomer() {
when(repository.findPlan("100", "IP-1")).thenReturn(null);
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20)));
verifyNoInteractions(stock, dispatch);
}
/** 当前实收值随请求一起参与版本及幂等校验。 */
private ConfirmInboundShortageRequest request(int version, int received) {
return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱");
}
}
@@ -324,6 +324,34 @@ class InboundStockReceiptServiceTest {
List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10)));
}
/** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */
@Test void exactSkuShortageStocksOnlyActualPackages() {
Fixture f = fixture(21, 21, 20, 210);
when(orders.selectBatchIds(any())).thenReturn(List.of(f.order));
when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20)));
when(inventory.stockIn(eq("100"), eq("operator"), any()))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false));
service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true);
var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals(20, request.getValue().packages());
assertEquals(21, f.detail.getPackageCount());
assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus());
assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus());
}
/** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */
@Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() {
Fixture f = fixture(21, 21, 20, 210);
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", 11), skuReceipt(12, 21, "SKU-2", 9)));
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task,
List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true));
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");
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */
class ShortageReceiptEvidencePersistenceTest {
/** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */
@Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception {
var f=new Fixture();
f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)");
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder(
new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1));
assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty();
f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'");
assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21));
}
/** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */
@Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception {
var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')");
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')");
f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')");
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2);
f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22");
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22");
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22");
f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20");
assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1);
assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero();
}
/** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */
@Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception {
var f=new Fixture();
f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1);
assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero();
assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero();
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero();
f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0");
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')");
assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero();
}
private static final class Fixture {
final JdbcTemplate db;
final ReceivingSkuReceiptMapper skus;
final DispatchSourceMapper dispatch;
/** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */
Fixture() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)");
db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)");
db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)");
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')");
db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')");
db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class);
}
}
}
@@ -33,6 +33,8 @@ class OrderWorkflowFactMapperTest {
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue());
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());