fix(lastleg): confirm inbound shortages and reconcile empty preplanned pallets
This commit is contained in:
+78
-2
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+153
@@ -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箱");
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user