fix(dispatch): confirm pallet quantities at inbound receipt
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.appointmentwork.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.*;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.work.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */
|
||||
class DispatchInboundReceiptPersistenceTest {
|
||||
/** 直接读取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","");
|
||||
var db=new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')");
|
||||
for(int n=1;n<=2;n++) {
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11);
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11);
|
||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||
db.update("UPDATE ll_inbound_plan SET status='RECEIVING'");
|
||||
assertThat(mapper.confirmedPlan("101",1)).isEmpty();
|
||||
}
|
||||
|
||||
/** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */
|
||||
@Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception {
|
||||
var f=new Fixture();f.confirm();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT picked_package_count FROM ll_dispatch_order",Integer.class)).isZero();
|
||||
verifyNoInteractions(f.p.inventory);
|
||||
var before=f.version();
|
||||
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(before,"receipt-nine",11L,9,0,null,null)));
|
||||
assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException();
|
||||
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"receipt-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
assertThat(f.binding.departureBlockReason(f.p.number)).isNull();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
verify(f.p.inventory,times(2)).ship(eq("101"),eq("picker"),any());
|
||||
}
|
||||
|
||||
/** 重复交接不修改版本、重复占用托盘或新增收货历史。 */
|
||||
@Test void confirmedReceiptIsIdempotent() throws Exception {
|
||||
var f=new Fixture();f.confirm();long version=f.version();
|
||||
f.p.f.tx.executeWithoutResult(s->{f.receipt.lock(List.of(1L,2L));f.receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now());});
|
||||
assertThat(f.version()).isEqualTo(version);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='RECEIVING_COMPLETE'",Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 已因其他事实变化失效的审核,不能在确认入库时被重新放行。 */
|
||||
@Test void receiptDoesNotApproveAnAlreadyStaleReview() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash='stale-review'");
|
||||
f.confirm();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT review_basis_hash FROM ll_dispatch_order",String.class)).isEqualTo("stale-review");
|
||||
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效");
|
||||
}
|
||||
|
||||
/** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */
|
||||
@Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception {
|
||||
var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true);
|
||||
f.confirm();
|
||||
assertThat(f.quantities()).containsExactly(9,11);
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认");
|
||||
}
|
||||
|
||||
/** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */
|
||||
@Test void receiptWithoutActiveDispatchDoesNotRequireTransportResources() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertThatCode(f::confirm).doesNotThrowAnyException();
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 客户仓沿本车逐票绑定交接9+11,首托扫码递增公共版本不能使第二托审核失效。 */
|
||||
@Test void privateWarehouseReceiptAndBothPicksKeepTheSameReview() throws Exception {
|
||||
var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId();
|
||||
f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'");
|
||||
f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch);
|
||||
f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE"));
|
||||
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();
|
||||
var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(f.version(),"private-nine",11L,9,0,null,null)));
|
||||
var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"private-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
assertThat(f.allocations()).containsExactly(9,11);
|
||||
}
|
||||
|
||||
/** 预约逐票交接失败时,收货数量、库存及排车实托绑定必须整体回滚。 */
|
||||
@Test void ambiguousBatchAllocationRollsBackTheWholeReceipt() throws Exception {
|
||||
var f=new Fixture();
|
||||
f.p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_id,dispatch_item_id,plan_pallet_id,'FBA_APPOINTMENT:2',customer_id,source_key,destination_key,1 FROM ll_dispatch_appointment_allocation WHERE plan_pallet_id=1");
|
||||
f.p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(2,0,'AP-2')");
|
||||
when(f.appointments.batchContext("FBA_APPOINTMENT:2")).thenReturn(new FbaAppointmentBatchContext(f.snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
assertThatThrownBy(f::confirm).hasMessageContaining("不能自动均分");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
assertThat(f.p.f.db.queryForObject("SELECT SUM(counted_package_count) FROM ll_inbound_plan_pallet_allocation",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 不能借收货确认接管其他排车已经占用的实托。 */
|
||||
@Test void occupiedPhysicalPalletRejectsReceiptWithoutPartialUpdates() throws Exception {
|
||||
var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
|
||||
assertThatThrownBy(f::confirm).hasMessageContaining("占用");
|
||||
assertThat(f.quantities()).containsExactly(10,10);
|
||||
assertThat(f.allocations()).containsExactly(10,10);
|
||||
}
|
||||
|
||||
/** 单客户同票两托的真实预约提供者,避免用恒定放行的预约Mock掩盖数量冲突。 */
|
||||
private static final class Fixture {
|
||||
final DispatchPickingPersistenceTest.Picking p=new DispatchPickingPersistenceTest.Picking(true);
|
||||
final InboundFbaAppointmentService appointments=mock(InboundFbaAppointmentService.class);
|
||||
final FbaAppointmentResponse snapshot=mock(FbaAppointmentResponse.class);
|
||||
final FbaReceivingStore receiving=mock(FbaReceivingStore.class);
|
||||
final AppointmentWorkDispatchBinding binding;
|
||||
final DispatchInboundReceiptService receipt;
|
||||
|
||||
/** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */
|
||||
Fixture() throws Exception {
|
||||
p.receivedPlans(9,11);
|
||||
p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0");
|
||||
p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'");
|
||||
p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)");
|
||||
p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8");
|
||||
p.f.candidates.get(2L).destinationWarehouseCode("ONT8");
|
||||
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(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);
|
||||
doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id);
|
||||
doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any());
|
||||
}
|
||||
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))");
|
||||
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
|
||||
p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')");
|
||||
for(long id=1;id<=2;id++) {
|
||||
p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id);
|
||||
p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,'FBA_APPOINTMENT:1','101','ORDER:9001','FBA:ONT8',10 FROM ll_dispatch_item WHERE plan_pallet_id=?",id);
|
||||
}
|
||||
p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(1,0,'AP-1')");
|
||||
// 与生产一致,排车和预约Mapper共用同一SqlSession,更新后一起失效本地查询缓存。
|
||||
p.f.session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
|
||||
var mapper=p.f.session.getMapper(AppointmentWorkDispatchMapper.class);
|
||||
var sources=mock(AppointmentSourceService.class);
|
||||
when(sources.canonicalOrders(anyList())).thenAnswer(call->{List<AppointmentExternalSourceGateway.OrderRef> refs=call.getArgument(0);return refs.stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList();});
|
||||
when(snapshot.version()).thenReturn(0);when(snapshot.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(snapshot.replacementBatchKeys()).thenReturn(List.of());
|
||||
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("101");when(item.sourceKey()).thenReturn("ORDER:9001");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(20);when(snapshot.items()).thenReturn(List.of(item));
|
||||
when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.now(ZoneOffset.UTC).plusDays(3),null,"SUCCESS",null,null,null);
|
||||
var member=new FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",0,"101",List.of(new FbaReceivingContracts.Source("ORDER:9001","FBA:ONT8",20,"v1",1,"IN-1")),null);
|
||||
when(receiving.currentAgreement("FBA_APPOINTMENT:1","101")).thenReturn(new FbaReceivingContracts.Agreement("AG-1",0,"ACTIVE","FBA:ONT8",details,"UTC",List.of(member),"确认收货"));
|
||||
binding=new AppointmentWorkDispatchBinding(mapper,appointments,sources,mock(AppointmentWorkChanges.class),receiving,null);
|
||||
var bridge=new AppointmentReceiptDispatchAdapter(new AppointmentReceiptRepository(mapper),binding,appointments,sources);
|
||||
var picking=(DispatchPickingRepository)ReflectionTestUtils.getField(p.service,"picking");
|
||||
receipt=new DispatchInboundReceiptService(p.f.repository,p.sources,picking,(DispatchPhysicalClaimService)ReflectionTestUtils.getField(p.f.service,"physicalClaims"),p.f.service,p.f.fleet,bridge);
|
||||
ReflectionTestUtils.setField(p.f.service,"appointments",new DispatchAppointmentAllocations(binding));
|
||||
p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(p.f.service,"basis",p.f.repository.find(p.number)));
|
||||
doAnswer(call->{var pick=(com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick)call.getArgument(1);long receiptId=pick.getInventoryPositionId()==11?1:2;
|
||||
p.f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,'101',?,9001,?,?,0)",pick.getId(),pick.getId(),receiptId,pick.getPackages());return List.of(new com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService.OrderPackages(9001L,pick.getPackages()));
|
||||
}).when(p.attribution).attribute(eq("101"),any(),isNull());
|
||||
}
|
||||
|
||||
/** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */
|
||||
void confirm() {
|
||||
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");
|
||||
receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC));
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取持久化实收箱数,不能只检查响应投影。 */
|
||||
List<Integer> quantities() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY plan_pallet_id",Integer.class); }
|
||||
/** 读取实际预约分配,覆盖本次遗漏的第二套账本。 */
|
||||
List<Integer> allocations() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_appointment_allocation ORDER BY plan_pallet_id",Integer.class); }
|
||||
/** 后续扫码必须使用收货交接后的公共版本。 */
|
||||
long version() { return p.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class); }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -259,7 +259,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class));
|
||||
ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins);
|
||||
picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json);
|
||||
ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
|
||||
ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
|
||||
pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins);
|
||||
balance(601,"101",5);position(101,601,"101",crossCustomer?2:5);
|
||||
if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);}
|
||||
|
||||
+7
-36
@@ -34,44 +34,16 @@ class DispatchPickingPersistenceTest {
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
|
||||
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
|
||||
/** 未在收货事务完成交接的原计划不能在出货时临时修数量。 */
|
||||
@Test void pickingCannotMaterializeAnUnconfirmedReceipt() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null))))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用");
|
||||
verify(fixture.inventory,never()).ship(any(),any(),any());
|
||||
new DispatchCommands.Pick(fixture.version,"receipt-not-confirmed",11L,9,0,null,null))))
|
||||
.hasMessageContaining("收货交接");
|
||||
verifyNoInteractions(fixture.inventory);
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
}
|
||||
/** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */
|
||||
@Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(9,11);
|
||||
var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null);
|
||||
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED");
|
||||
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest));
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,
|
||||
new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any());
|
||||
assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */
|
||||
@Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception {
|
||||
var fixture=new Picking(true);fixture.receivedPlans(8,11);
|
||||
doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
|
||||
new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
|
||||
}
|
||||
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
|
||||
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
|
||||
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
|
||||
@@ -113,7 +85,7 @@ class DispatchPickingPersistenceTest {
|
||||
}
|
||||
|
||||
/** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */
|
||||
private static final class Picking {
|
||||
static final class Picking {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
|
||||
@@ -147,8 +119,7 @@ class DispatchPickingPersistenceTest {
|
||||
sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet);
|
||||
for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field));
|
||||
}
|
||||
/** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */
|
||||
void receivedPlans(int first,int second) {
|
||||
|
||||
+19
-52
@@ -33,62 +33,30 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
assertThat(mapper.receivedPlanCompleted("101",3L)).isZero();
|
||||
}
|
||||
}
|
||||
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
|
||||
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
|
||||
var f=new Fixture();var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
|
||||
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
verify(f.sources).stock("101",501L,false);
|
||||
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
|
||||
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
/** 在途和来源身份变化均不成为可拣库存。 */
|
||||
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
|
||||
var f=new Fixture();var row=f.f.candidates.get(1L);
|
||||
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */
|
||||
@Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception {
|
||||
/** PDA查询只读取确认入库时已经交接的事实,不在GET补写或投影修正计划数量。 */
|
||||
@Test void pendingReceiptDoesNotExposeStockOrReplaceEstimates() throws Exception {
|
||||
var f=new Fixture(true);var before=f.dbState();
|
||||
var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(10L,10L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isEmpty());
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
verifyNoInteractions(f.sources,f.rules);
|
||||
}
|
||||
|
||||
/** 收货已保存9+11时,PDA直接显示9+11并提供实际托盘仓位。 */
|
||||
@Test void confirmedReceiptUsesPersistedNineAndEleven() throws Exception {
|
||||
var f=new Fixture(true);
|
||||
for(int n=1;n<=2;n++) {
|
||||
int count=n==1?9:11;
|
||||
f.f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=?,source_stage='RECEIVED',package_count=? WHERE plan_pallet_id=?",500+n,count,n);
|
||||
f.f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages) VALUES(?,'101',?,?,'9',?,0,0,0)",10+n,20+n,500+n,count);
|
||||
}
|
||||
var before=f.dbState();var task=f.read();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty());
|
||||
assertThat(task.counts().packageCount()).isEqualTo(20);
|
||||
assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).hasSize(1));
|
||||
assertThat(f.dbState()).isEqualTo(before);
|
||||
}
|
||||
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
|
||||
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
|
||||
var f=new Fixture();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
|
||||
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
|
||||
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
|
||||
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
|
||||
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
|
||||
var f=new Fixture();
|
||||
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
|
||||
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1),
|
||||
new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) {
|
||||
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
}
|
||||
}
|
||||
/** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */
|
||||
@Test void receivingPlanDoesNotExposePartialPallet() throws Exception {
|
||||
var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false);
|
||||
assertThat(f.read().items().get(0).positions()).isEmpty();
|
||||
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -109,7 +77,6 @@ class DispatchPlanPdaReadPersistenceTest {
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
|
||||
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
|
||||
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
|
||||
when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true);
|
||||
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
|
||||
|
||||
+10
@@ -33,11 +33,13 @@ class InboundPdaContainerCompletionTest {
|
||||
@Mock InboundStockReceiptService stockReceipts;
|
||||
@Mock InboundUnloadCompletedOutboxService outbox;
|
||||
@Mock LastlegOrderMapper orders;
|
||||
@Mock com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
|
||||
private InboundPdaWorkflowService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", dispatchReceipts);
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox);
|
||||
@@ -74,6 +76,14 @@ class InboundPdaContainerCompletionTest {
|
||||
any(Instant.class), any(Instant.class), anyLong());
|
||||
verify(repository, times(2)).update(any(InboundPlan.class));
|
||||
verify(repository, times(2)).update(any(InboundPdaTask.class));
|
||||
// 运输父锁必须早于收货来源锁;两票库存落账后才交接原排车,并且只交接一次。
|
||||
var sequence=org.mockito.Mockito.inOrder(dispatchReceipts,repository,stockReceipts);
|
||||
sequence.verify(dispatchReceipts).lock(List.of(2L,1L));
|
||||
sequence.verify(repository).lockTask("100",1L);
|
||||
sequence.verify(stockReceipts).stockIn(eq("100"),any(),any(),any(),any(),any(),any());
|
||||
sequence.verify(stockReceipts).stockIn(eq("200"),any(),any(),any(),any(),any(),any());
|
||||
sequence.verify(dispatchReceipts).complete(eq(List.of(1L,2L)),org.mockito.ArgumentMatchers.isNull(),any());
|
||||
verify(dispatchReceipts,times(1)).complete(any(),any(),any());
|
||||
}
|
||||
|
||||
private static InboundPlan plan(long id, String planNo) {
|
||||
|
||||
+2
@@ -71,6 +71,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
@@ -91,6 +92,7 @@ class InboundPdaWorkflowServiceTest {
|
||||
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
|
||||
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
|
||||
lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
|
||||
lenient().when(repository.findPlan("100", "LIP1")).thenReturn(plan);
|
||||
LastlegOrder sourceOrder = new LastlegOrder();
|
||||
sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1");
|
||||
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
|
||||
|
||||
+2
@@ -83,6 +83,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ReceivingContainerService();
|
||||
ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class));
|
||||
ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository);
|
||||
org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation ->
|
||||
sessions.findActiveByOperator(invocation.getArgument(0)));
|
||||
@@ -1166,6 +1167,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A");
|
||||
order.setInboundCode("IN-1");
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
org.mockito.Mockito.lenient().when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||
when(plans.selectList(any())).thenReturn(List.of(plan));
|
||||
when(lines.selectList(any())).thenReturn(List.of(line));
|
||||
org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order));
|
||||
|
||||
Reference in New Issue
Block a user