优化
This commit is contained in:
+8
-5
@@ -17,17 +17,17 @@ class AppointmentImpactRelationTest {
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)");
|
||||
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR,status VARCHAR,stage VARCHAR)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT UNIQUE,dispatch_no VARCHAR,trip_id BIGINT UNIQUE)");
|
||||
s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)");
|
||||
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')");
|
||||
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)");
|
||||
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'DS-1','ACTIVE','BEFORE_LOADING'),(2,'DS-WRONG','ACTIVE','BEFORE_LOADING')");
|
||||
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2),(2,'B',1)");
|
||||
s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')");
|
||||
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)");
|
||||
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'DS-1',1),(2,'DS-WRONG',2)");
|
||||
s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
@@ -40,6 +40,9 @@ class AppointmentImpactRelationTest {
|
||||
assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
|
||||
assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1");
|
||||
assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B");
|
||||
assertThat(relations.dispatches("20")).extracting(AppointmentImpactTransportGateway.Dispatch::dispatchNo).containsExactly("DS-1");
|
||||
assertThat(new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("20")).trips())
|
||||
.extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ class Phase4BrowserApiBoundaryTest {
|
||||
.filter(method -> method.isAnnotationPresent(PostMapping.class))
|
||||
.flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value()))
|
||||
.collect(Collectors.toSet());
|
||||
assertEquals(Set.of("/drafts/snapshot", "/pre-arrange", "/drafts/pre-arrange", "/drafts/containers",
|
||||
assertEquals(Set.of("/drafts/snapshot", "/drafts", "/drafts/pre-arrange", "/drafts/containers",
|
||||
"/draft-locks/lock", "/draft-locks/unlock", "/draft-locks/release"), postPaths);
|
||||
assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(DeleteMapping.class)).count() == 1);
|
||||
|
||||
+10
-1
@@ -15,7 +15,16 @@ public final class DeliveryDispatchFixtures {
|
||||
return new CargoLine(id,customer,"客户"+customer,id,"LM-"+id,"SOURCE-"+id,"RECEIVED","LOCAL",null,"IN-"+id,
|
||||
null,pallet,"PALLET-"+pallet,id,id,id,id,null,null,null,null,
|
||||
new Destination("FBA:"+destination,"FBA",null,destination,destination,null,null,null,null),
|
||||
packages,0,null,null,List.of());
|
||||
packages,0,null,null,List.of(),null);
|
||||
}
|
||||
/** 换标沿用真实履约订单及逐次拣货归属,原货源单独进入冻结快照。 */
|
||||
public static CargoLine relabelCargo(long id,String customer,String destination,long pallet,long packages,SourceOrigin origin) {
|
||||
var row=cargo(id,customer,destination,pallet,packages);
|
||||
return new CargoLine(row.dispatchItemId(),row.customerId(),row.customerName(),row.orderId(),row.orderNo(),
|
||||
row.sourceKey(),"RELABEL",row.sourceRegion(),row.sourceInboundId(),row.inboundCode(),row.planPalletId(),
|
||||
row.warehousePalletId(),row.palletNo(),row.pickId(),row.attributionId(),row.inventoryPositionId(),null,null,null,
|
||||
row.cartonMark(),"RL-"+origin.relabelApplicationId(),row.destination(),row.packages(),row.productQuantity(),
|
||||
row.grossWeightKg(),row.volumeCbm(),row.batchAllocations(),origin);
|
||||
}
|
||||
/** 构造同仓完整拣货的整车上下文,发车后只改变业务计数。 */
|
||||
public static Context context(List<CargoLine> cargo,boolean departed) {
|
||||
|
||||
+11
-9
@@ -39,12 +39,13 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
DeliverySourceEvidenceMapper mapper;
|
||||
volatile Runnable beforeFirstSourceRead = () -> {};
|
||||
final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>();
|
||||
/** 原生整车和唯一任务没有客户头,发布证据沿真实明细、扫码与来源行读取。 */
|
||||
Fixture() {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
@@ -131,10 +132,11 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); }
|
||||
void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); }
|
||||
void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); }
|
||||
/** 创建真实客户明细成员及原生departed_at,保留签收与重派的确定时间顺序。 */
|
||||
void shipment(long id,int packages) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-01-01 01:00:00')",id);
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id);
|
||||
}
|
||||
@@ -156,17 +158,17 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1);
|
||||
// Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent.
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1));
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1));
|
||||
f.pick(3,2,101,2,30L);
|
||||
// Keep deterministic chronology without sleeping or depending on wall clock granularity.
|
||||
f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");
|
||||
f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");
|
||||
f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102");
|
||||
}
|
||||
@Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() {
|
||||
var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first");
|
||||
f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1");
|
||||
f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0));
|
||||
f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2");
|
||||
f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-04 00:00:00' WHERE id=2");
|
||||
f.pick(2,2,101,4,30L);f.sign(2,"repeat-four");
|
||||
assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY");
|
||||
}
|
||||
@@ -211,9 +213,9 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
@Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() {
|
||||
var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null);
|
||||
f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty();
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L);
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L);
|
||||
f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty();
|
||||
f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L);
|
||||
f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L);
|
||||
f.sign(3,"third");assertThat(f.published()).containsExactly("S-101");
|
||||
}
|
||||
@Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() {
|
||||
|
||||
+73
-2
@@ -42,7 +42,7 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json);
|
||||
private final Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false);
|
||||
private Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false);
|
||||
private final Actor actor=new Actor("TEST",ActorMode.PLATFORM,null);
|
||||
|
||||
/** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */
|
||||
@@ -79,7 +79,7 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
|
||||
service=(DispatchDeliveryService)proxy.getProxy();
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenReturn(context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(fees.external(any())).thenReturn(Map.of("101",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()),
|
||||
"102",new FleetBookingFreight.Preview("freight",true,List.of(),List.of())));
|
||||
when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenThrow(new IllegalStateException("主单CAS失败"));
|
||||
@@ -102,6 +102,77 @@ class DispatchDeliveryArrangementPersistenceTest {
|
||||
verify(execution,never()).recordArrangement(any(),any(),any(),any(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 真实 Mapper 保存安排和逐次归属后,出库及派送均可独立定位原货源,不依赖可变履约单回填。 */
|
||||
@Test void persistedAttributionAndDeliveryReferencesResolveImmutableRelabelOrigins() throws Exception {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null);
|
||||
var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L);
|
||||
context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second),
|
||||
relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false);
|
||||
db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(3,'101','INBOUND_COMPLETED',0),(4,'101','INBOUND_COMPLETED',0)");
|
||||
// 成功用例覆盖默认 CAS 失败桩时不能调用旧桩;保留失败用例原有回滚语义。
|
||||
doAnswer(a->{
|
||||
ArrangementFact fact=a.getArgument(3);CommandIdentity identity=a.getArgument(2);
|
||||
return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,identity.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING,
|
||||
DEPARTED,Effect.APPLIED,context.counts(),fact.result(),false,false);
|
||||
}).when(execution).recordArrangement(any(),any(),any(),any(),anyBoolean());
|
||||
var result=service.arrange(actor,"DP-10",request());
|
||||
assertNotNull(result);
|
||||
assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pallet_item",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
|
||||
assertEquals(3,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
|
||||
assertEquals(5,db.queryForObject("SELECT package_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class));
|
||||
assertEquals(1,db.queryForObject("SELECT pallet_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT pallet_count FROM ll_outbound_order WHERE customer_id='101'",Integer.class));
|
||||
|
||||
// 修改履约单展示事实后,仍只通过原安排快照和已保存的真实归属定位来源。
|
||||
db.update("UPDATE ll_order SET order_no=CONCAT('CHANGED-',id)");
|
||||
var outboundRows=db.queryForList("""
|
||||
SELECT i.customer_id,i.source_attribution_id,i.source_order_id,i.dispatch_item_id,i.package_count,
|
||||
i.gross_weight_kg,i.volume_cbm,CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
|
||||
FROM ll_outbound_pallet_item i
|
||||
JOIN ll_outbound_order o ON o.id=i.outbound_order_id AND o.customer_id=i.customer_id
|
||||
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=o.dispatch_order_id
|
||||
ORDER BY i.source_attribution_id
|
||||
""");
|
||||
for (var item:outboundRows) {
|
||||
List<CargoLine> frozen=json.readValue(item.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
long attribution=((Number)item.get("SOURCE_ATTRIBUTION_ID")).longValue();
|
||||
var row=frozen.stream().filter(c->c.customerId().equals(item.get("CUSTOMER_ID"))&&c.attributionId()==attribution)
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(context.cargo().stream().filter(c->c.attributionId()==attribution).findFirst().orElseThrow().sourceOrigin(),row.sourceOrigin());
|
||||
assertEquals(row.orderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue());
|
||||
assertNotEquals(row.sourceOrigin().sourceInboundOrderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue());
|
||||
assertEquals(row.packages(),((Number)item.get("PACKAGE_COUNT")).longValue());
|
||||
assertNull(item.get("GROSS_WEIGHT_KG"));assertNull(item.get("VOLUME_CBM"));
|
||||
}
|
||||
// 费用的 DELIVERY_ORDER.businessId 指向此真实派送行,可按客户及目的地取回其全部冻结来源。
|
||||
var deliveryRows=db.queryForList("""
|
||||
SELECT d.id,d.customer_id,d.delivery_group_key,d.package_count,d.pallet_count,
|
||||
CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
|
||||
FROM ll_delivery_order d
|
||||
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=d.dispatch_order_id
|
||||
ORDER BY d.customer_id,d.delivery_group_key
|
||||
""");
|
||||
int referencedSources=0;
|
||||
for (var delivery:deliveryRows) {
|
||||
List<CargoLine> frozen=json.readValue(delivery.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
var group=frozen.stream().filter(c->c.customerId().equals(delivery.get("CUSTOMER_ID"))
|
||||
&& grouping.documentGroupKey(c.destination()).equals(delivery.get("DELIVERY_GROUP_KEY"))).toList();
|
||||
referencedSources+=group.size();
|
||||
assertFalse(group.isEmpty());assertTrue(group.stream().allMatch(c->c.sourceOrigin()!=null));
|
||||
assertEquals(((Number)delivery.get("PACKAGE_COUNT")).longValue(),group.stream().mapToLong(CargoLine::packages).sum());
|
||||
assertEquals(((Number)delivery.get("PALLET_COUNT")).longValue(),group.stream().map(CargoLine::warehousePalletId).distinct().count());
|
||||
if (group.size()==2) assertEquals(List.of(first,second),group.stream().map(CargoLine::sourceOrigin).toList());
|
||||
}
|
||||
assertEquals(4,referencedSources);
|
||||
List<CargoLine> frozen=json.readValue(db.queryForObject("SELECT cargo_json FROM ll_dispatch_delivery_arrangement",String.class),
|
||||
json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
assertEquals(3,frozen.stream().filter(c->c.sourceOrigin().productQuantity()==null).count());
|
||||
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
|
||||
}
|
||||
|
||||
/** 按真实共享记录构造完整客户目的地请求。 */
|
||||
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
|
||||
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
|
||||
|
||||
+24
-2
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
|
||||
import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.SourceOrigin;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
@@ -80,7 +81,7 @@ class DispatchDeliveryFeesTest {
|
||||
var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3));
|
||||
var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(),
|
||||
raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(),
|
||||
raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations());
|
||||
raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations(),raw.sourceOrigin());
|
||||
var group=grouping.groups(context(List.of(first,second),false)).get(0);
|
||||
when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101))));
|
||||
var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of());
|
||||
@@ -92,6 +93,27 @@ class DispatchDeliveryFeesTest {
|
||||
input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary()));
|
||||
}
|
||||
|
||||
/** 多个换标原货源共组费用只归真实派送,不借第一票原货源或履约单伪造单一归属。 */
|
||||
@Test void relabelFeeRetainsDeliveryReferenceWithoutInventingOneOrigin() {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),
|
||||
relabelCargo(3,"101","ONT8",51,3,second)),false);
|
||||
var group=grouping.groups(context).get(0);
|
||||
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");
|
||||
delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
|
||||
service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL");
|
||||
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);
|
||||
verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
|
||||
assertEquals("DELIVERY_ORDER",saved.getValue().businessType());
|
||||
assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo());
|
||||
assertEquals("101",saved.getValue().customerId());
|
||||
assertNull(saved.getValue().orderId());assertNull(saved.getValue().inboundId());assertNull(saved.getValue().inboundCode());
|
||||
assertEquals(List.of(first,second),group.cargo().stream().map(CargoLine::sourceOrigin).toList());
|
||||
assertTrue(group.cargo().stream().allMatch(c->c.sourceOrigin().productQuantity()==null&&c.grossWeightKg()==null&&c.volumeCbm()==null));
|
||||
assertEquals(5,group.packages());assertEquals(1,group.palletCount());
|
||||
}
|
||||
|
||||
/** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */
|
||||
@Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception {
|
||||
var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class);
|
||||
@@ -109,7 +131,7 @@ class DispatchDeliveryFeesTest {
|
||||
/** 真实头程关联只在来源行声明,不从展示编号推断。 */
|
||||
private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(),
|
||||
"FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(),
|
||||
c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations()); }
|
||||
c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations(),c.sourceOrigin()); }
|
||||
/** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */
|
||||
private ArrangeGroup input(DeliveryCargoGroups.Group group,List<CoverageSelection> coverage,List<FeeItem> fees) {
|
||||
return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees);
|
||||
|
||||
+37
@@ -72,6 +72,43 @@ class DispatchDeliveryServiceTest {
|
||||
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
|
||||
}
|
||||
|
||||
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
|
||||
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
|
||||
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
|
||||
var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null);
|
||||
var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null);
|
||||
var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L);
|
||||
context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second),
|
||||
relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false);
|
||||
var result=(ArrangementResult)service.arrange(actor,"DP-10",request("RELABEL-ARRANGE")).result();
|
||||
assertEquals(2,result.outbounds().size());
|
||||
assertEquals(3,result.deliveries().size());
|
||||
var ont8=result.deliveries().stream().filter(d->d.customerId().equals("101")&&d.destination().warehouseCode().equals("ONT8"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(5,ont8.packageCount());assertEquals(1,ont8.palletCount());
|
||||
assertEquals(2,result.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8"))
|
||||
.map(DeliveryReference::customerId).distinct().count());
|
||||
List<CargoLine> cargo=json.readValue(arrangement.getCargoJson(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class));
|
||||
assertEquals(context.cargo(),cargo);
|
||||
var created=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(repository,times(4)).create(created.capture());
|
||||
for (var item:created.getAllValues()) {
|
||||
var source=cargo.stream().filter(c->c.customerId().equals(item.getCustomerId())
|
||||
&& c.attributionId().equals(item.getSourceAttributionId())).findFirst().orElseThrow();
|
||||
assertEquals(source.orderId(),item.getSourceOrderId());
|
||||
assertNotEquals(source.sourceOrigin().sourceInboundOrderId(),item.getSourceOrderId());
|
||||
assertEquals(source.dispatchItemId(),item.getDispatchItemId());
|
||||
assertEquals(source.packages(),item.getPackageCount().longValue());
|
||||
assertNull(item.getGrossWeightKg());assertNull(item.getVolumeCbm());
|
||||
}
|
||||
assertEquals(3,cargo.stream().filter(c->c.sourceOrigin().productQuantity()==null).count());
|
||||
assertTrue(outbounds.stream().allMatch(o->o.getGrossWeightKg()==null&&o.getVolumeCbm()==null));
|
||||
assertTrue(deliveries.stream().allMatch(d->d.getTotalWeightKg()==null&&d.getTotalVolumeCbm()==null));
|
||||
var orders=org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(repository,times(4)).lockSource(anyString(),orders.capture());
|
||||
assertEquals(List.of(1L,2L,3L,4L),orders.getAllValues());
|
||||
}
|
||||
|
||||
/** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */
|
||||
@Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() {
|
||||
var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING,
|
||||
|
||||
+31
-11
@@ -14,13 +14,11 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class DispatchFeeCargoMapperTest {
|
||||
/** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */
|
||||
@Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() {
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
|
||||
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
|
||||
item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A");
|
||||
item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
|
||||
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
|
||||
assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> {
|
||||
var query = nativeGateway(item);
|
||||
assertThat(query.sourceCargo("DP-1", "101")).singleElement().satisfies(source -> {
|
||||
assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
@@ -29,17 +27,28 @@ class DispatchFeeCargoMapperTest {
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"})
|
||||
void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound);
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101");
|
||||
var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L);
|
||||
item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8");
|
||||
var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery(
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper));
|
||||
var sources = query.sources(dispatch, java.util.List.of(item));
|
||||
var query = nativeGateway(item);
|
||||
var sources = query.sourceCargo("DP-1", "101");
|
||||
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B");
|
||||
assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10);
|
||||
item.setPackageCount(19);
|
||||
assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致");
|
||||
assertThatThrownBy(() -> query.sourceCargo("DP-1", "101")).hasMessageContaining("箱数未核平");
|
||||
}
|
||||
/** 使用当前Fleet整车来源入口,隔离无关资源写链,不保留旧客户主单查询类。 */
|
||||
private com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter nativeGateway(com.baiyue.lastleg.dispatch.entity.DispatchItem item) {
|
||||
var aggregates = org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class);
|
||||
var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchAggregate();
|
||||
dispatch.setId(1L); dispatch.setDispatchNo("DP-1");
|
||||
item.setCustomerId("101"); item.setDispatchOrderId(1L);
|
||||
org.mockito.Mockito.when(aggregates.find("DP-1")).thenReturn(dispatch);
|
||||
org.mockito.Mockito.when(aggregates.cargo(1L)).thenReturn(java.util.List.of(item));
|
||||
return new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(aggregates,null,null,null,
|
||||
new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper),null,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
}
|
||||
|
||||
JdbcTemplate jdbc; DispatchFeeCargoMapper mapper;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
@@ -48,7 +57,7 @@ class DispatchFeeCargoMapperTest {
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)");
|
||||
@@ -56,7 +65,7 @@ class DispatchFeeCargoMapperTest {
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,1),(602,2)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
@@ -69,6 +78,17 @@ class DispatchFeeCargoMapperTest {
|
||||
assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L);
|
||||
assertThat(mapper.list("202", 1)).isEmpty();
|
||||
}
|
||||
/** 同一整车与任务内按真实明细和拣货客户分别统计,不借其他客户货量。 */
|
||||
@Test void sharedDispatchAndTaskKeepEachCustomersSourcesSeparate() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(202,1,'202',501,'PLT-501','WH-1',NULL,'IN-OTHER')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'202',202,103,7)");
|
||||
assertThat(mapper.list("101",1)).extracting(row -> row.packages()).containsExactly(10L,10L);
|
||||
assertThat(mapper.list("202",1)).singleElement().satisfies(row -> {
|
||||
assertThat(row.inboundCode()).isEqualTo("IN-OTHER");
|
||||
assertThat(row.packages()).isEqualTo(57L);
|
||||
});
|
||||
assertThat(mapper.list("303",1)).isEmpty();
|
||||
}
|
||||
@Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)");
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchAggregateController;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -124,7 +124,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "vehicleAppointment", "remark"),
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark"),
|
||||
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
|
||||
@@ -184,8 +184,8 @@ class PlatformDispatchOutboundContractTest {
|
||||
|
||||
@Test
|
||||
void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception {
|
||||
String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class);
|
||||
String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class);
|
||||
String dispatchFind = select(DispatchAggregateMapper.class, "find", String.class);
|
||||
String dispatchLock = select(DispatchAggregateMapper.class, "lock", String.class);
|
||||
String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class);
|
||||
String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class);
|
||||
|
||||
|
||||
+5
-4
@@ -18,13 +18,14 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** Two real database transactions execute the production mapper SQL, with bounded waits. */
|
||||
class SourceDeliveryCompletionConcurrencyTest {
|
||||
/** 两个真实签收事务共享来源锁,最后持锁者必须读到前一签收提交。 */
|
||||
@Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa","");
|
||||
var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
@@ -33,9 +34,9 @@ class SourceDeliveryCompletionConcurrencyTest {
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)");
|
||||
for (int i=1;i<=2;i++) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-10-03 01:00:00')",i);
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i);
|
||||
|
||||
+25
-22
@@ -15,9 +15,9 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
@@ -99,7 +99,7 @@ class DeliveryTransferServiceTest {
|
||||
.valueToTree(detail).path("task").path("outboundType").asText());
|
||||
}
|
||||
|
||||
@Mock DispatchOrderMapper dispatchOrders;
|
||||
@Mock DispatchAggregateRepository dispatches;
|
||||
@Mock DispatchItemMapper dispatchItems;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock OutboundPalletItemMapper outboundPallets;
|
||||
@@ -128,21 +128,19 @@ class DeliveryTransferServiceTest {
|
||||
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
|
||||
|
||||
private DeliveryTransferService service;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
@Mock com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "execution", execution);
|
||||
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
outboundOrders,outboundPallets,deliveryOrders,pdaTasks,deliveryOperations,
|
||||
mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class),returnCartons,
|
||||
mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),tailOrders,histories));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
|
||||
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
||||
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets);
|
||||
@@ -665,7 +663,11 @@ class DeliveryTransferServiceTest {
|
||||
.getMethod("deliveryPickedSkus", String.class, Long.class);
|
||||
String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
|
||||
assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}"));
|
||||
assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}"));
|
||||
assertTrue(sql.contains("BINARY p.customer_id=BINARY #{customerId}"));
|
||||
assertTrue(sql.contains("di.dispatch_order_id=t.dispatch_order_id"));
|
||||
assertTrue(sql.contains("BINARY di.customer_id=BINARY p.customer_id"));
|
||||
assertTrue(sql.contains("BINARY a.customer_id=BINARY p.customer_id"));
|
||||
assertTrue(!sql.contains("t.customer_id"));
|
||||
assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END"));
|
||||
assertTrue(sql.contains("COALESCE(a.packages,p.packages)"));
|
||||
assertTrue(!sql.contains("ll_order_detail"));
|
||||
@@ -748,9 +750,9 @@ class DeliveryTransferServiceTest {
|
||||
.map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining()));
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any());
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any());
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution, fleetBindings);
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution);
|
||||
ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(execution).lockForArrangement(any(),eq("DSP-101"));
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L);
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301");
|
||||
service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试"));
|
||||
verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any());
|
||||
}
|
||||
@@ -966,10 +968,10 @@ class DeliveryTransferServiceTest {
|
||||
OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
DispatchAggregate dispatch = new DispatchAggregate();
|
||||
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch);
|
||||
when(dispatches.find("DSP-401")).thenReturn(dispatch);
|
||||
|
||||
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
|
||||
|
||||
@@ -978,6 +980,18 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
}
|
||||
|
||||
/** 直接扫排车号使用无客户头整车,并保持PDA绑定仓隔离。 */
|
||||
@Test
|
||||
void directDispatchNumberResolvesOnlyInItsWarehouse() {
|
||||
DispatchAggregate dispatch = new DispatchAggregate();
|
||||
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
|
||||
when(dispatches.find("DSP-401")).thenReturn(dispatch);
|
||||
assertEquals("DSP-401", service.resolvePdaTask("WH-1", "DSP-401").dispatchNo());
|
||||
ResponseStatusException denied = assertThrows(ResponseStatusException.class,
|
||||
() -> service.resolvePdaTask("WH-2", "DSP-401"));
|
||||
assertEquals(404, denied.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundNumberResolvesOneClickTaskInBoundWarehouse() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
@@ -1264,17 +1278,6 @@ class DeliveryTransferServiceTest {
|
||||
|
||||
|
||||
|
||||
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART");
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
|
||||
String destinationName, String inboundCode, int packages) {
|
||||
DispatchItem value = new DispatchItem();
|
||||
|
||||
+232
-32
@@ -8,6 +8,31 @@ import com.baiyue.lastleg.returnreceipt.mapper.*;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchExecutionPort;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -104,40 +129,215 @@ class ReturnOutboundClosureTest {
|
||||
verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() {
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
|
||||
outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
|
||||
application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1");
|
||||
application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application);
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK");
|
||||
item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
|
||||
carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L);
|
||||
service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"));
|
||||
assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING");
|
||||
assertThat(outbound.getStatus()).isEqualTo("CANCELLED");
|
||||
verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"),
|
||||
argThat(request -> "CANCELLED".equals(request.status())));
|
||||
var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService);
|
||||
sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1");
|
||||
sequence.verify(outbounds).lockGlobalByNo("OUT1");
|
||||
sequence.verify(cartons).lockMark("RC#1-1");
|
||||
sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
/** 实际取消只撤销安排,已确认申请、换标照片/逐件事实及原库存保持不变。 */
|
||||
@Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() throws Exception {
|
||||
var f = new CancellationFixture(); var proof = f.proof();
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING");
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pda_task", "status", 20)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_delivery_order", "status", 60)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND");
|
||||
assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "current_status", 70)).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
assertThat(f.locks.subList(0,3)).containsExactly("relabel", "outbound", "carton");
|
||||
verifyNoInteractions(f.relabelLifecycle, f.execution);
|
||||
var after = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(after);
|
||||
assertThatThrownBy(() -> f.service.pdaPick("1", "picker", "TASK1",
|
||||
new DeliveryRequests.PdaPick(40L,null,1L,"old-outbound-pick",List.of("RC#1-1"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("not available for picking");
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
}
|
||||
|
||||
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() {
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1");
|
||||
outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound);
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1);
|
||||
when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item));
|
||||
assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")))
|
||||
@Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() throws Exception {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_outbound_pallet_item SET picked_package_count=1 WHERE id=40");
|
||||
f.insert("ll_outbound_pda_pick", "id", 90, "customer_id", "C1", "task_id", 20,
|
||||
"outbound_pallet_item_id", 40, "packages", 1, "idempotency_key", "actual-pick");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel)
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started");
|
||||
verifyNoInteractions(relabelService);
|
||||
verify(cartons, never()).updateById(any(ReturnInventoryCarton.class));
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Long.class)).isEqualTo(1);
|
||||
verifyNoInteractions(f.relabelLifecycle, f.execution, f.fees);
|
||||
}
|
||||
|
||||
/** 普通仓托换标同样只取消出库安排,不释放真实原入仓位置的HOLD或重做换标。 */
|
||||
@Test void cancelsUntouchedWarehousePalletRelabelWithoutReleasingItsHold() throws Exception {
|
||||
var f = new CancellationFixture(); f.warehousePalletSource(); var proof = f.proof();
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION");
|
||||
assertThat(f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)).isEqualTo(1);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.proof()).isEqualTo(proof);
|
||||
assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull();
|
||||
assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull();
|
||||
verifyNoInteractions(f.relabelLifecycle,f.execution);
|
||||
var cancelled = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(cancelled);
|
||||
}
|
||||
|
||||
/** 原出库号不匹配不能通过申请ID或其他客户的同类记录恢复关联。 */
|
||||
@Test void cancellationRejectsChangedRelabelAssociationWithoutAnyWrite() throws Exception {
|
||||
for (String column : List.of("outbound_order_no", "customer_id", "overseas_warehouse_id")) {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_relabel_application SET " + column + "=? WHERE id=50", "other");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("关联状态已变化");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
}
|
||||
}
|
||||
|
||||
/** 相同箱号也必须属于原出库的客户和仓,不能解除另一个客户的库存预留。 */
|
||||
@Test void cancellationRejectsForeignCustomerOrWarehouseCarton() throws Exception {
|
||||
for (String column : List.of("customer_id", "overseas_warehouse_id")) {
|
||||
var f = new CancellationFixture();
|
||||
f.db.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=10", "other");
|
||||
var original = f.state();
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("预留状态异常");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
}
|
||||
}
|
||||
|
||||
/** 在申请和箱预留已实际恢复后注入下游失败,所有关联、任务及派送状态必须整体回滚。 */
|
||||
@Test void laterCancellationFailureRollsBackTheWholeArrangementAndAllowsRetry() throws Exception {
|
||||
var f = new CancellationFixture(); var original = f.state();
|
||||
when(f.fees.listForBusiness("C1", "DELIVERY_ORDER", 60L)).thenAnswer(call -> {
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING");
|
||||
throw new IllegalStateException("取消后续核对失败");
|
||||
});
|
||||
assertThatThrownBy(f::cancel).isInstanceOf(IllegalStateException.class).hasMessage("取消后续核对失败");
|
||||
assertThat(f.state()).isEqualTo(original);
|
||||
doReturn(List.of()).when(f.fees).listForBusiness("C1", "DELIVERY_ORDER", 60L);
|
||||
f.cancel();
|
||||
assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED");
|
||||
assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull();
|
||||
}
|
||||
|
||||
/** 真实数据库装配取消仓储与全部Mapper;仅隔离费用查询等外部端口,不伪造取消成功。 */
|
||||
private static final class CancellationFixture {
|
||||
final JdbcTemplate db;
|
||||
final DeliveryTransferService service;
|
||||
final OverseasFeeSourceService fees = mock(OverseasFeeSourceService.class);
|
||||
final DispatchExecutionPort execution = mock(DispatchExecutionPort.class);
|
||||
final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelLifecycle = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class);
|
||||
final List<String> locks = new ArrayList<>();
|
||||
|
||||
/** 按统一初始化实际建表,保留列、默认值及约束,不以Java字段猜测正式结构。 */
|
||||
CancellationFixture() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:cancel-native-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(ds);
|
||||
String schema = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for (String table : List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_pda_pick",
|
||||
"ll_delivery_order", "ll_delivery_operation", "ll_relabel_application", "ll_return_inventory_carton",
|
||||
"ll_return_inventory_carton_item", "ll_warehouse_transfer_order", "ll_order", "ll_order_status_history",
|
||||
"ll_relabel_item", "ll_relabel_pda_task", "ll_relabel_pda_unit_fact", "ll_relabel_pda_history",
|
||||
"ll_inventory_balance", "ll_inventory_position", "ll_inventory_ledger")) {
|
||||
int start = schema.indexOf("CREATE TABLE " + table + " ("); int end = schema.indexOf(") ENGINE=", start);
|
||||
if (start < 0 || end < 0) throw new IllegalStateException("统一初始化缺少取消测试表:" + table);
|
||||
db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b", "CLOB")
|
||||
.replaceAll("(?i)\\s+CHARACTER SET\\s+\\w+", "").replaceAll("(?i)\\s+COLLATE\\s+\\w+", "")
|
||||
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)", "$1 " + table + "_$2"));
|
||||
}
|
||||
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
for (var type : List.of(OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class,
|
||||
DeliveryOrderMapper.class,DeliveryOperationMapper.class,RelabelApplicationMapper.class,ReturnInventoryCartonMapper.class,
|
||||
WarehouseTransferOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class)) config.addMapper(type);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
|
||||
var outboundMapper = traced(sessions.getMapper(OutboundOrderMapper.class), OutboundOrderMapper.class, "lockGlobalByNo", "outbound");
|
||||
var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel");
|
||||
var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton");
|
||||
var target = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class),
|
||||
sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper,
|
||||
cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
ReflectionTestUtils.setField(target,"outboundOrders",outboundMapper); ReflectionTestUtils.setField(target,"outboundPallets",sessions.getMapper(OutboundPalletItemMapper.class));
|
||||
ReflectionTestUtils.setField(target,"pdaTasks",sessions.getMapper(OutboundPdaTaskMapper.class)); ReflectionTestUtils.setField(target,"pdaPicks",sessions.getMapper(OutboundPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(target,"feeSources",fees); ReflectionTestUtils.setField(target,"execution",execution);
|
||||
ReflectionTestUtils.setField(target,"relabelService",relabelLifecycle);
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
|
||||
service = (DeliveryTransferService)proxy.getProxy();
|
||||
when(fees.listForBusiness(anyString(),anyString(),anyLong())).thenReturn(List.of());
|
||||
seed();
|
||||
}
|
||||
|
||||
/** 记录真实Mapper的取锁次序,调用及SQL均委托原MyBatis实现。 */
|
||||
private <T> T traced(T actual,Class<T> type,String method,String label) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class<?>[]{type},(proxy,called,args) -> {
|
||||
if (called.getName().equals(method)) locks.add(label);
|
||||
try { return called.invoke(actual,args); }
|
||||
catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); }
|
||||
}));
|
||||
}
|
||||
|
||||
/** 已确认原生换标、未扫码独立出库及其真实派送/来源关联。 */
|
||||
private void seed() {
|
||||
var at = LocalDateTime.of(2026,10,19,12,0);
|
||||
insert("ll_outbound_order","id",30,"customer_id","C1","outbound_no","OUT1","overseas_warehouse_id","1","outbound_type","RELABEL_OUTBOUND","status","WAIT_PICK","idempotency_key","OUT1-CREATE");
|
||||
insert("ll_outbound_pallet_item","id",40,"customer_id","C1","outbound_order_id",30,"carton_marks","RC#1-1","package_count",1,"status","WAIT_PICK");
|
||||
insert("ll_outbound_pda_task","id",20,"customer_id","C1","outbound_order_id",30,"task_no","TASK1","status","PENDING");
|
||||
insert("ll_delivery_order","id",60,"customer_id","C1","delivery_no","DEL1","outbound_order_id",30,"outbound_no","OUT1","delivery_group_key","NATIVE","status","WAIT_DELIVERY","idempotency_key","DEL1-CREATE");
|
||||
insert("ll_relabel_application","id",50,"customer_id","C1","relabel_no","REL1","lastleg_order_id",70,"customer_order_no","CUSTOMER-1","overseas_warehouse_id","1","label_deadline",at.plusDays(1),"contact_name","测试","contact_phone","10000000000","delivery_method","EXPRESS","outbound_order_no","OUT1","status","READY_FOR_OUTBOUND","fulfillment_status","OUTBOUND_CREATED","customer_confirmed_at",at,"customer_confirmation_key","CONFIRMED-1","wallet_status","SETTLED","idempotency_key","REL1-CREATE","request_hash","a".repeat(64));
|
||||
insert("ll_return_inventory_carton","id",10,"customer_id","C1","overseas_warehouse_id","1","carton_mark","RC#1-1","status","OUTBOUND_PENDING","relabel_application_id",50,"relabel_no","REL1","relabel_status","COMPLETED","relabel_completed_at",at,"sku_types",1,"total_quantity",6,"created_at",at,"updated_at",at);
|
||||
insert("ll_return_inventory_carton_item","id",11,"customer_id","C1","carton_id",10,"sku_code","NEW-SKU","quantity",6);
|
||||
insert("ll_order","id",70,"customer_id","C1","order_no","RELABEL-ORDER","order_type","RELABEL_APPLICATION","source_region","OVERSEAS","source_inbound_id","REL1","source_event_id","REL1","source_loading_plan_id","REL1","source_payload_hash","a".repeat(64),"relabel_order_no","REL1","relabel_status","COMPLETED","current_status","WAIT_OUTBOUND","outbound_order_no","OUT1","delivery_order_no","DEL1");
|
||||
insert("ll_relabel_item","id",71,"application_id",50,"customer_id","C1","product_name_zh","测试","total_package_count",1,"notified_package_count",1,"return_carton_id",10,"operation_type","CARTON_LABEL","carton_mark","NEW-BOX");
|
||||
insert("ll_relabel_pda_task","id",80,"application_id",50,"customer_id","C1","relabel_no","REL1","overseas_warehouse_id",1,"status","COMPLETED","operator_key","picker","operator_name","测试","expected_packages",1,"relabelled_packages",1,"claimed_at",at,"completion_photo_file_ids","[501]","reviewed_at",at);
|
||||
insert("ll_relabel_pda_unit_fact","id",81,"task_id",80,"application_id",50,"item_id",71,"customer_id","C1","old_label","RC#1-1","new_label","NEW-BOX","result","RELABELLED","quantity",1,"operator_key","picker","operator_name","测试","occurred_at",at);
|
||||
insert("ll_relabel_pda_history","id",82,"task_id",80,"application_id",50,"customer_id","C1","action","COMPLETE","to_status","WAIT_REVIEW","operator_key","picker","operator_name","测试","detail_json","{\"roundKey\":\"INITIAL\",\"photoFileIds\":[501]}");
|
||||
}
|
||||
|
||||
/** 使用真实原入仓余额和仓位表示普通已换标仓托;原生退货箱记录不参与此场景。 */
|
||||
void warehousePalletSource() {
|
||||
db.update("DELETE FROM ll_return_inventory_carton_item"); db.update("DELETE FROM ll_return_inventory_carton");
|
||||
db.update("UPDATE ll_outbound_pallet_item SET warehouse_pallet_id=501,pallet_no='PL-501',carton_marks='BOX-ORD' WHERE id=40");
|
||||
db.update("UPDATE ll_relabel_item SET return_carton_id=NULL,source_inventory_position_id=101,source_inventory_balance_id=601,source_inbound_order_id=701,source_inbound_order_no='IN-701',source_inbound_code='IN-701' WHERE id=71");
|
||||
db.update("UPDATE ll_relabel_pda_unit_fact SET old_label='BOX-ORD' WHERE id=81");
|
||||
insert("ll_order","id",701,"customer_id","C1","order_no","ORIGINAL-INBOUND","source_region","OVERSEAS","source_inbound_id","IN-701","source_event_id","IN-701","source_loading_plan_id","IN-701","source_payload_hash","b".repeat(64),"current_status","INBOUND_COMPLETED");
|
||||
insert("ll_inventory_balance","id",601,"customer_id","C1","customer_name","测试客户","overseas_warehouse_id","1","inbound_order_id",701,"inbound_code","IN-701","hold_packages",1);
|
||||
insert("ll_inventory_position","id",101,"customer_id","C1","balance_id",601,"overseas_warehouse_id","1","pallet_id",501,"pallet_no","PL-501","warehouse_location_code","LOC-1","hold_packages",1);
|
||||
}
|
||||
|
||||
/** 每次取消经同一个实际Spring事务入口。 */
|
||||
void cancel() { service.cancelOutbound("OUT1","OP1",new DeliveryRequests.Cancel("cancel")); }
|
||||
/** 合成数据只用于测试准备,生产取消全部通过真实仓储和Mapper。 */
|
||||
void insert(String table,Object... fields) {
|
||||
var columns = new ArrayList<String>(); var values = new ArrayList<Object>();
|
||||
for(int i=0;i<fields.length;i+=2){columns.add((String)fields[i]);values.add(fields[i+1]);}
|
||||
db.update("INSERT INTO " + table + "(" + String.join(",",columns) + ") VALUES(" + String.join(",",Collections.nCopies(values.size(),"?")) + ")",values.toArray());
|
||||
}
|
||||
/** 从数据库核对已提交状态,不读取服务内预读对象。 */
|
||||
String text(String table,String column,long id) { return db.queryForObject("SELECT " + column + " FROM " + table + " WHERE id=?",String.class,id); }
|
||||
/** 换标成果和箱内SKU数量不应因取消出库而变化。 */
|
||||
List<List<Map<String,Object>>> proof() {
|
||||
return List.of(db.queryForList("SELECT status,delivery_method,customer_confirmed_at,customer_confirmation_key,wallet_status FROM ll_relabel_application"),
|
||||
db.queryForList("SELECT relabel_application_id,relabel_no,relabel_status,relabel_completed_at,total_quantity,sku_types FROM ll_return_inventory_carton"),
|
||||
db.queryForList("SELECT * FROM ll_return_inventory_carton_item"),
|
||||
db.queryForList("SELECT status,reviewed_at,CAST(completion_photo_file_ids AS VARCHAR) photos FROM ll_relabel_pda_task"),
|
||||
db.queryForList("SELECT * FROM ll_relabel_pda_unit_fact"),
|
||||
db.queryForList("SELECT id,action,CAST(detail_json AS VARCHAR) detail FROM ll_relabel_pda_history"),
|
||||
db.queryForList("SELECT * FROM ll_inventory_balance"),db.queryForList("SELECT * FROM ll_inventory_position"),
|
||||
db.queryForList("SELECT * FROM ll_inventory_ledger"));
|
||||
}
|
||||
/** 全部可变安排记录在失败和重放时必须与原提交状态一致。 */
|
||||
List<List<Map<String,Object>>> state() {
|
||||
return List.of(db.queryForList("SELECT status,outbound_order_no,fulfillment_status,updated_at FROM ll_relabel_application"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_return_inventory_carton"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_outbound_order"),db.queryForList("SELECT status FROM ll_outbound_pallet_item"),
|
||||
db.queryForList("SELECT status,updated_at FROM ll_outbound_pda_task"),db.queryForList("SELECT status,version,updated_at FROM ll_delivery_order"),
|
||||
db.queryForList("SELECT current_status,outbound_order_no,delivery_order_no,version FROM ll_order"),db.queryForList("SELECT * FROM ll_delivery_operation"),
|
||||
List.of(Map.of("proof",proof())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -16,6 +16,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
|
||||
@Test
|
||||
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
|
||||
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
|
||||
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
|
||||
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
|
||||
int end = sql.indexOf(" AS overseasWarehouseId", start);
|
||||
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
|
||||
String expression = sql.substring(start, end).replace(">", ">").replace("<", "<");
|
||||
assertTrue(expression.endsWith("END AS CHAR(128))"));
|
||||
assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"));
|
||||
String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1)
|
||||
.replace("#{warehouseId}", "?").replace("</script>", "");
|
||||
String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression
|
||||
+ " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)"
|
||||
+ warehouseFilter;
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
|
||||
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
|
||||
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
|
||||
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
|
||||
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
|
||||
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
|
||||
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
|
||||
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
|
||||
statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002'),"
|
||||
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
|
||||
try (var prepared = connection.prepareStatement(query)) {
|
||||
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
|
||||
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
|
||||
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
|
||||
assertWarehouseRows(prepared, invalid, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */
|
||||
private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List<Long> expected) throws Exception {
|
||||
prepared.setString(1, warehouse);
|
||||
var actual = new ArrayList<Long>();
|
||||
try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); }
|
||||
actual.sort(Long::compareTo);
|
||||
assertEquals(expected, actual, warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception {
|
||||
for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) {
|
||||
@@ -200,7 +251,9 @@ class DispatchCandidateMapperContractTest {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("pos.overseas_warehouse_id"));
|
||||
assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains(method.getName().equals("available")
|
||||
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
|
||||
: "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')"));
|
||||
assertTrue(sql.contains("source_order.id=ip.source_order_id"));
|
||||
assertTrue(sql.contains("source_order.customer_id=ip.customer_id"));
|
||||
assertTrue(sql.contains("source_order.overseas_warehouse_id"));
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ class DispatchPhysicalWeightTest {
|
||||
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)");
|
||||
int tested = 0;
|
||||
var tested = new java.util.HashSet<String>();
|
||||
for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) {
|
||||
var annotation = method.getAnnotation(Select.class);
|
||||
if (annotation == null) continue;
|
||||
@@ -43,8 +43,8 @@ class DispatchPhysicalWeightTest {
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40");
|
||||
jdbc.update("DELETE FROM ll_order_detail");
|
||||
tested++;
|
||||
tested.add(method.getName());
|
||||
}
|
||||
assertThat(tested).isEqualTo(2);
|
||||
assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-22
@@ -1,32 +1,39 @@
|
||||
package com.baiyue.lastleg.dispatch.fleet;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.service.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetDispatchPlanningAdapterTest {
|
||||
private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
|
||||
private final DispatchService service=mock(DispatchService.class);
|
||||
private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
|
||||
private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway);
|
||||
final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
|
||||
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
|
||||
final DispatchService rules=mock(DispatchService.class);
|
||||
final DispatchAggregateService main=mock(DispatchAggregateService.class);
|
||||
final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
|
||||
final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,sources,rules,gateway,main);
|
||||
@AfterEach void clear(){SecurityContextHolder.clearContext();}
|
||||
private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);}
|
||||
@Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() {
|
||||
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2"));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车");
|
||||
verifyNoInteractions(service);
|
||||
@Test void keepsOtherCustomerAndOtherInboundCargoWhileVerifyingTheExactReceiptSubset(){
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",50));
|
||||
when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"),new FleetDispatchGateway.SourceCargo("OTHER-IN","2","P-2",7,"FBA:ONT8")));
|
||||
adapter.validateIncomingDispatch("DS-1","9",receipt());verify(gateway).sourceCargo("DS-1","9");verifyNoInteractions(main);
|
||||
when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("未完整覆盖");
|
||||
}
|
||||
@Test void reusesOnlyAnExactExistingShipment() {
|
||||
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1"));
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10));
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
|
||||
assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service);
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收");
|
||||
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2")));
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源");
|
||||
@Test void joinsTheTripExistingMainAndDoesNotCreateASecondCustomerDispatch(){
|
||||
var user=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of());SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of()));
|
||||
when(mapper.dispatchForTrip("TR-1")).thenReturn("DS-1");var context=mock(DispatchContracts.Context.class);when(context.dispatchId()).thenReturn(1L);when(main.lockForArrangement(any(),eq("DS-1"))).thenReturn(context);
|
||||
when(mapper.selectedPlans(1,"9")).thenReturn(List.of(10L));when(mapper.receivedPlanPallets("9","IN-1")).thenReturn(List.of(10L));
|
||||
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",10));when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
|
||||
assertThat(adapter.resolveIncoming("TR-1","9",receipt(),"1","booking-original")).isEqualTo("DS-1");verify(main,never()).create(any(),any());verify(main,never()).addCargo(any(),any(),any());verifyNoInteractions(sources,rules);
|
||||
assertThatThrownBy(()->adapter.resolveIncoming("TR-1","9",receipt(),"forged","booking-original")).hasMessageContaining("操作者");
|
||||
}
|
||||
@Test void wrongWarehouseCannotBeAcceptedByMatchingReceiptTotals(){when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","OTHER",10));assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("起运仓");verify(gateway,never()).sourceCargo(any(),any());}
|
||||
}
|
||||
|
||||
+1
-4
@@ -16,7 +16,7 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetPlanningReadPersistenceTest {
|
||||
@Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception {
|
||||
@Test void externalSourcesKeepActualCustomerAndUnknownQuantities() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))");
|
||||
@@ -38,9 +38,6 @@ class FleetPlanningReadPersistenceTest {
|
||||
var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
|
||||
var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class);
|
||||
var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null);
|
||||
assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1);
|
||||
assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1");
|
||||
var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper());
|
||||
var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6")));
|
||||
assertThat(sources).hasSize(2);
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 实际Mapper查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */
|
||||
class DispatchSourceMapperWarehouseBindingTest {
|
||||
private static final long WAREHOUSE = 193000000000200003L;
|
||||
private static final long PHYSICAL_PALLET = 2112349761578209281L;
|
||||
private static final long PLAN = 2112349764502786049L;
|
||||
private static final long PLAN_PALLET = 2112349764502786050L;
|
||||
private static final String CUSTOMER = "2112349740878327809";
|
||||
private static final String SECOND_CUSTOMER = "2112349740878327810";
|
||||
private JdbcTemplate db;
|
||||
private DispatchSourceMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(data);
|
||||
// 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)");
|
||||
plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(DispatchSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestedWarehouseMustMatchTheRealStockPosition() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() {
|
||||
db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() {
|
||||
db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() {
|
||||
db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() {
|
||||
plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET);
|
||||
position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET);
|
||||
// 无本仓库存的第三份计划不能混入当前组成。
|
||||
plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET);
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET)))
|
||||
.containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER),
|
||||
new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() {
|
||||
assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"})
|
||||
void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) {
|
||||
db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse);
|
||||
// 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。
|
||||
assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty();
|
||||
}
|
||||
|
||||
private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer,
|
||||
long warehouse, long physicalPallet) {
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet);
|
||||
}
|
||||
|
||||
private void position(long positionId, String customer, String warehouse, long pallet) {
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet);
|
||||
}
|
||||
}
|
||||
+32
-372
@@ -1,380 +1,40 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class DispatchPdaWorkflowServiceTest {
|
||||
@Mock DispatchOrderMapper orders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock OutboundPalletItemMapper outboundPalletItems;
|
||||
@Mock DispatchItemMapper items;
|
||||
@Mock DispatchPdaTaskMapper tasks;
|
||||
@Mock DispatchPdaPickMapper picks;
|
||||
@Mock DispatchPdaOperationMapper operations;
|
||||
@Mock DispatchPalletLockMapper palletLocks;
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock DispatchPickOrderAttributionService attribution;
|
||||
@Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
private DispatchPdaWorkflowService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "picks", picks);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "palletLocks", palletLocks);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "pickAttribution", attribution);
|
||||
ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
final DispatchPdaWorkflowService service=new DispatchPdaWorkflowService();
|
||||
final DispatchPickingRepository picks=mock(DispatchPickingRepository.class);
|
||||
final DispatchSourceDepartureRepository source=mock(DispatchSourceDepartureRepository.class);
|
||||
final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final FleetPoolOrderBridge fleet=mock(FleetPoolOrderBridge.class);
|
||||
final RelabelApplicationService relabels=mock(RelabelApplicationService.class);
|
||||
final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
|
||||
@BeforeEach void setup(){ReflectionTestUtils.setField(service,"wholePicks",picks);ReflectionTestUtils.setField(service,"wholeDeparture",source);ReflectionTestUtils.setField(service,"pickAttribution",attribution);ReflectionTestUtils.setField(service,"fleetPool",fleet);ReflectionTestUtils.setField(service,"relabelApplications",relabels);}
|
||||
@Test void departureAdvancesEachRealCustomerAndUsesItsOwnImmutablePicks(){
|
||||
var context=context(List.of(cargo("102",22L,5,null),cargo("101",11L,3,null)));
|
||||
var a=List.of(new DispatchPdaPick());var b=List.of(new DispatchPdaPick());when(picks.picks("101",1)).thenReturn(a);when(picks.picks("102",1)).thenReturn(b);
|
||||
var at=Instant.parse("2026-10-19T10:00:00Z");service.departWholeSources(context,actor,result("101","102"),at);
|
||||
var local=LocalDateTime.ofInstant(at,ZoneOffset.UTC);verify(attribution).depart("101","OUT-101","DP-ONE",a,local);verify(attribution).depart("102","OUT-102","DP-ONE",b,local);
|
||||
verify(source).transferOrder("101",11L,local);verify(source).transferOrder("102",22L,local);
|
||||
verify(fleet).departed("101","DP-ONE",Map.of(11L,3),"operator",local);verify(fleet).departed("102","DP-ONE",Map.of(22L,5),"operator",local);
|
||||
verifyNoInteractions(relabels);
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
|
||||
assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT"));
|
||||
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8"));
|
||||
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT"));
|
||||
@Test void missingActualPicksCannotAdvanceAnySourceOrFleetOrder(){
|
||||
assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("101"),Instant.now())).hasMessageContaining("真实扫码");verifyNoInteractions(source,attribution,fleet,relabels);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING");
|
||||
when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of());
|
||||
|
||||
List<DispatchPdaResponses.Task> result = service.list("WH-1", null);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("DSP-1", result.get(0).dispatchNo());
|
||||
verify(tasks).listByWarehouse("WH-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() {
|
||||
when(orders.findGlobalByDispatchNo("DSP-1"))
|
||||
.thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE"));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.task("WH-1", "DSP-1"));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(tasks, never()).findByDispatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskExposesTransferDeliveryGateBeforeDeparture() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
item.setInboundCode("IN-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
|
||||
DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1");
|
||||
|
||||
assertFalse(result.canDepart());
|
||||
assertEquals("请先在平台完成“转派送”,生成待出库单后才能发车。", result.departBlockReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskExposesDepartureWhenPendingOutboundExists() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1");
|
||||
|
||||
assertTrue(result.canDepart());
|
||||
assertNull(result.departBlockReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPickRetryReturnsPersistedResultAfterTaskAutomaticallyCompleted() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
task.setOperatorId("7");
|
||||
DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null);
|
||||
DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false);
|
||||
DispatchPdaOperation replay = operation("PICK", request, persisted);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(operations.findKey("100", "pick-1")).thenReturn(replay);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
|
||||
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
|
||||
|
||||
assertEquals(2, result.packages());
|
||||
verify(fleetBindings, never()).requirePickingApproved(anyLong());
|
||||
verify(inventory, never()).allocate(any(), any(), any());
|
||||
verify(picks, never()).insert((DispatchPdaPick) any());
|
||||
verify(items, never()).bindReceivedPallets(any(), any());
|
||||
var locks = org.mockito.Mockito.inOrder(orders, tasks);
|
||||
locks.verify(orders).findGlobalByDispatchNo("DSP-1");
|
||||
locks.verify(orders).lockById("100", 1L);
|
||||
locks.verify(tasks).lockByDispatch("100", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRejectsPartialPickingWithoutStatusDrift() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1")));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("PICKING", order.getStatus());
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertFalse(first.replayed());
|
||||
verify(operations).insert((DispatchPdaOperation) any());
|
||||
|
||||
DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first);
|
||||
when(operations.findKey("100", "done-1")).thenReturn(replay);
|
||||
DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertTrue(second.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departRequiresFullyCompletedPickingAndPendingOutbound() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).updateById((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货"))
|
||||
.when(fleetBindings).requirePickingApproved(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete")));
|
||||
org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test void boundDispatchCannotDepartBeforeTripCoordination() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车"))
|
||||
.when(fleetBindings).requireCoordinatedDeparture(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulDeparturePersistsBusinessDepartureTime() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
item.setInboundCode("IN-1");
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(1L);
|
||||
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
|
||||
OutboundPalletItem outboundItem = new OutboundPalletItem();
|
||||
outboundItem.setWarehousePalletId(41L); outboundItem.setInboundCode("IN-1");
|
||||
outboundItem.setPackageCount(6); outboundItem.setPickedPackageCount(0); outboundItem.setStatus("WAIT_PICK");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
when(outboundPalletItems.findByOutbound("100", 1L)).thenReturn(List.of(outboundItem));
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart(
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertNotNull(order.getDepartAt());
|
||||
assertEquals(order.getDepartAt(), order.getUpdatedAt());
|
||||
verify(orders).updateById(order);
|
||||
assertEquals("DEPARTED", order.getStatus());
|
||||
assertEquals("OUTBOUNDED", outbound.getStatus());
|
||||
assertEquals(6, outboundItem.getPickedPackageCount());
|
||||
assertEquals("PICKED", outboundItem.getStatus());
|
||||
verify(outboundPalletItems).updateById(outboundItem);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED");
|
||||
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
|
||||
DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertTrue(result.replayed());
|
||||
}
|
||||
|
||||
private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception {
|
||||
DispatchPdaOperation value = new DispatchPdaOperation();
|
||||
value.setTaskId(11L); value.setOperationType(type); value.setStatus("COMPLETED");
|
||||
value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))));
|
||||
value.setResultJson(objectMapper.writeValueAsString(result));
|
||||
return value;
|
||||
}
|
||||
|
||||
@Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() {
|
||||
var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
|
||||
order.setVehiclePlate("CA-100");
|
||||
var task = task(11L, "100", "WH-1", "PDA_PENDING");
|
||||
var item = item(21L, "100", 2);
|
||||
item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.selectById(21L)).thenReturn(item);
|
||||
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L);
|
||||
when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
|
||||
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton);
|
||||
var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员",
|
||||
new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1"));
|
||||
assertEquals(1L, result.pickedPackages());
|
||||
var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks);
|
||||
sequence.verify(attribution).prepareNativeReturn("100", 101L);
|
||||
sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
|
||||
org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"));
|
||||
sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null));
|
||||
sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L));
|
||||
org.mockito.Mockito.verifyNoInteractions(inventory, positions);
|
||||
}
|
||||
|
||||
private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
value.setOverseasWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) {
|
||||
DispatchPdaTask value = new DispatchPdaTask();
|
||||
value.setId(id); value.setDispatchOrderId(1L);
|
||||
value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchItem item(Long id, String customerId, int packages) {
|
||||
DispatchItem value = new DispatchItem();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
|
||||
value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages);
|
||||
return value;
|
||||
@Test void missingCustomerOutboundCannotBorrowAnotherCustomersDocument(){
|
||||
assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("102"),Instant.now())).hasMessageContaining("本客户实际出库单");verifyNoInteractions(picks,source,attribution,fleet,relabels);
|
||||
}
|
||||
private static DispatchContracts.CargoLine cargo(String customer,long order,long packages,String relabel){var c=mock(DispatchContracts.CargoLine.class);when(c.customerId()).thenReturn(customer);when(c.orderId()).thenReturn(order);when(c.packages()).thenReturn(packages);when(c.relabelNo()).thenReturn(relabel);return c;}
|
||||
private static DispatchContracts.Context context(List<DispatchContracts.CargoLine> cargo){var c=mock(DispatchContracts.Context.class);when(c.cargo()).thenReturn(cargo);when(c.dispatchId()).thenReturn(1L);when(c.dispatchNo()).thenReturn("DP-ONE");return c;}
|
||||
private static DispatchDeliveryModels.DepartureResult result(String... customers){return new DispatchDeliveryModels.DepartureResult(Arrays.stream(customers).map(c->new DispatchDeliveryModels.OutboundReference(c,null,"OUT-"+c,"OUTBOUNDED",0,0)).toList(),List.of(),0);}
|
||||
}
|
||||
|
||||
+9
-9
@@ -62,7 +62,7 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1);
|
||||
DispatchPdaPick pick = pick(81L, 91L, 5L, 50L);
|
||||
|
||||
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick);
|
||||
List<DispatchPickOrderAttributionService.OrderPackages> result = service.attribute("t1", pick, null);
|
||||
|
||||
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L),
|
||||
new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result);
|
||||
@@ -94,7 +94,7 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(2L,102L,1L,1L),
|
||||
receipt(1L,101L,1L,1L),receipt(3L,102L,1L,1L)));
|
||||
when(orders.lockById(eq("t1"),anyLong())).thenAnswer(call -> new LastlegOrder());
|
||||
service().preparePicking("t1",91L,null);
|
||||
service().preparePicking("t1",91L);
|
||||
var sequence=inOrder(attributions,orders);
|
||||
sequence.verify(attributions).availableReceipts("t1",91L);
|
||||
sequence.verify(orders).lockById("t1",101L);sequence.verify(orders).lockById("t1",102L);
|
||||
@@ -105,15 +105,15 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L)));
|
||||
var held=new LastlegOrder();held.setDetained(true);
|
||||
when(orders.lockById("t1",101L)).thenReturn(held).thenReturn(null);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
verifyNoInteractions(history);
|
||||
}
|
||||
@Test void changedSourceSetAfterLockingFailsClosed() {
|
||||
when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L)))
|
||||
.thenReturn(List.of(receipt(2L,102L,1L,1L)));
|
||||
when(orders.lockById("t1",101L)).thenReturn(new LastlegOrder());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L));
|
||||
verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong());
|
||||
}
|
||||
|
||||
@@ -125,22 +125,22 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1);
|
||||
var service = service();
|
||||
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)),
|
||||
service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L));
|
||||
service.attribute("t1", pick(81L, 91L, 2L, 0L), 201L));
|
||||
var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class);
|
||||
verify(attributions).insert(capture.capture());
|
||||
assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId());
|
||||
when(returnReceipts.picked("t1", 201L)).thenReturn(2L);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L));
|
||||
() -> service.attribute("t1", pick(82L, 91L, 1L, 0L), 201L));
|
||||
}
|
||||
|
||||
@Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() {
|
||||
@Test void selectedReturnCannotLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() {
|
||||
var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt();
|
||||
receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L);
|
||||
when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt));
|
||||
when(returnReceipts.lock("t1", 201L)).thenReturn(receipt);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L));
|
||||
() -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 201L));
|
||||
verify(attributions, never()).insert(any(DispatchPdaPickOrder.class));
|
||||
verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong());
|
||||
verify(attributions, never()).availableReceipts(anyString(), anyLong());
|
||||
|
||||
+3
@@ -36,6 +36,9 @@ public final class DispatchPortCompileContract {
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本");
|
||||
}
|
||||
require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位");
|
||||
require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位");
|
||||
require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留");
|
||||
require(!fields(DispatchCommands.Create.class).contains("customerId"), "整车创建无单客户头部");
|
||||
require(!fields(DispatchContracts.Context.class).contains("customerId"), "整车上下文无伪客户");
|
||||
require(fields(DispatchContracts.CargoLine.class).contains("customerId"), "真实来源保留客户");
|
||||
|
||||
+5
-8
@@ -22,7 +22,7 @@ public final class DispatchPublicJsonContract {
|
||||
/** 测试执行器不允许实例化。 */
|
||||
private DispatchPublicJsonContract() { }
|
||||
|
||||
/** 覆盖缺版本、显式零、预约只写引用及所有动作结果持久JSON恢复。 */
|
||||
/** 覆盖缺版本、显式零、移除车辆预约字段及所有动作结果持久JSON恢复。 */
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectMapper requests = mapper();
|
||||
for (Class<?> request : new Class<?>[] {
|
||||
@@ -42,11 +42,8 @@ public final class DispatchPublicJsonContract {
|
||||
Object zero = requests.readValue("{\"recordVersion\":0}", request);
|
||||
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
|
||||
}
|
||||
var reference = requests.readValue("{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0}",
|
||||
DispatchCommands.AppointmentReference.class);
|
||||
require("EXISTING-AP".equals(reference.referenceNo()), "预约只保留权威引用");
|
||||
rejectsMissing(requests, DispatchCommands.AppointmentReference.class,
|
||||
"{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0,\"status\":\"CONFIRMED\",\"appointmentAt\":\"2026-10-19T00:00:00Z\"}");
|
||||
rejectsMissing(requests, DispatchCommands.Update.class,
|
||||
"{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}");
|
||||
|
||||
Instant now = Instant.parse("2026-10-19T00:00:00Z");
|
||||
var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1);
|
||||
@@ -80,7 +77,7 @@ public final class DispatchPublicJsonContract {
|
||||
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
|
||||
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
|
||||
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; appointment reference; all action JSON restart/replay)");
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
|
||||
}
|
||||
|
||||
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
|
||||
@@ -97,7 +94,7 @@ public final class DispatchPublicJsonContract {
|
||||
private static void rejectsMissing(ObjectMapper mapper, Class<?> type, String payload) throws Exception {
|
||||
try { mapper.readValue(payload, type); }
|
||||
catch (JsonProcessingException expected) { return; }
|
||||
throw new AssertionError("请求必须拒绝缺失/非法版本或伪造预约字段:" + type.getSimpleName());
|
||||
throw new AssertionError("请求必须拒绝缺失/非法版本或已移除字段:" + type.getSimpleName());
|
||||
}
|
||||
|
||||
/** 离线合同失败时终止,不模拟接口成功。 */
|
||||
|
||||
+194
-7
@@ -35,6 +35,166 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
|
||||
public class DispatchAggregatePersistenceTest {
|
||||
/** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */
|
||||
@Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.allowFile(81L);var before=e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.files,times(2)).metadata(71L,81L);verify(e.files,times(2)).download(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
assertThat(e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number)).isEqualTo(before);
|
||||
}
|
||||
|
||||
/** 主档后来换证不改写本车冻结证照;旧附件必须仍满足该司机的历史关联规则。 */
|
||||
@Test void frozenHistoricalDriverLicenseRemainsReadableAfterMasterReplacement() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(1L);e.allowFile(81L);
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3);
|
||||
verify(e.history,times(2)).selectCount(any());verify(e.files,times(2)).metadata(71L,81L);verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 外配附件仍限本车证据;不能因为共用DRIVER展示类型而改走正式司机档案。 */
|
||||
@Test void externalDriverEvidenceRetainsExactDispatchOwnerForBothReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.EXTERNAL,null,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(81L,"external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,81L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,81L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,81L)).containsExactly(4,5,6);
|
||||
verify(e.dispatchFiles).metadata(dispatch,kind,81L);verify(e.dispatchFiles).download(dispatch,kind,81L);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
|
||||
/** 冻结引用不绕过司机当前或历史关联,提供者明确拒绝后不得尝试排车类别。 */
|
||||
@Test void unlinkedFrozenMasterFileCannotFallBackToDispatchEvidence() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(0L);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("未关联");
|
||||
verifyNoInteractions(e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** File拒绝主档四元组时直接失败,不以另一业务类别或其他司机重新读取。 */
|
||||
@Test void masterFileProviderRejectionCannotFallBackToAnotherCategory() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
when(e.files.metadata(71L,81L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配");
|
||||
verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */
|
||||
@Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"new-external.pdf","application/pdf",3L);
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,91L)).thenReturn(new byte[]{4,5,6});
|
||||
assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(metadata);
|
||||
assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,91L)).containsExactly(4,5,6);
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
assertThat(e.f.db.queryForObject("SELECT resources_json FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number)).contains("MASTER").doesNotContain("91");
|
||||
}
|
||||
|
||||
/** 未列入MASTER冻结集合或声明为外配的档案文件只能接受本车四元组核验,失败后不转司机域。 */
|
||||
@Test void foreignOrWrongModeMasterFilesNeverGainDriverDomainAccess() throws Exception {
|
||||
for(var mode:List.of(ResourceMode.MASTER,ResourceMode.EXTERNAL)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(mode,71L,mode==ResourceMode.MASTER?List.of(81L):List.of(91L));
|
||||
var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
when(e.dispatchFiles.download(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配"));
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files);
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中冻结文件集合仍须有本车真实正数司机身份,不能拿车号或其他司机替代。 */
|
||||
@Test void masterFileWithMissingOrInvalidDriverIdentityFailsBeforeAnyFileProvider() throws Exception {
|
||||
for(Long driverId:Arrays.asList(null,0L,-1L)) {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,driverId,List.of(81L));
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无法解释的资源模式不选择任何文件域,防止异常快照放宽读取。 */
|
||||
@Test void unknownDriverModeFailsBeforeEitherFileProvider() throws Exception {
|
||||
var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L));
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET resources_json=REPLACE(resources_json,'MASTER','UNKNOWN') WHERE dispatch_no=?",number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败");
|
||||
verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles);
|
||||
}
|
||||
|
||||
/** 使用真实排车Mapper/资源JSON及司机Service,只替代外部文件内容和主档持久提供者。 */
|
||||
private static final class DriverEvidenceFixture {
|
||||
final Fixture f;final DriverMapper driverRows=mock(DriverMapper.class);final DriverHistoryMapper history=mock(DriverHistoryMapper.class);
|
||||
final DriverFileGateway files=mock(DriverFileGateway.class);final DriverEntity driver=new DriverEntity();
|
||||
final com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway dispatchFiles;
|
||||
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
|
||||
DriverEvidenceFixture() throws Exception {
|
||||
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
|
||||
var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
|
||||
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
|
||||
}
|
||||
/** 在真实主单保存服务端资源快照,HTTP请求本身不能指定司机、模式或允许文件集合。 */
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
void allowFile(long fileId) {
|
||||
var metadata=new DriverModels.File(Long.toString(fileId),"driver.pdf","application/pdf",3L);
|
||||
when(files.metadata(71L,fileId)).thenReturn(metadata);when(files.download(71L,fileId)).thenReturn(new byte[]{1,2,3});
|
||||
}
|
||||
}
|
||||
/** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */
|
||||
@Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of())));
|
||||
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
|
||||
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
|
||||
assertThat(created.recordVersion()).isZero();
|
||||
var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED");
|
||||
verifyNoInteractions(facts,sources,serial);
|
||||
}
|
||||
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */
|
||||
@Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -42,10 +202,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
var removeRequest=new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-original","重新分车");
|
||||
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest));
|
||||
assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501 AND active_warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();
|
||||
var newOwner=f.tx.execute(s->f.service.create(f.actor,f.create("new-owner",List.of(a))));
|
||||
assertThat(f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId());
|
||||
assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId());
|
||||
var cancelRequest=new DispatchCommands.ReasonCommand(removed.recordVersion(),"cancel-original","本趟取消");
|
||||
var cancelled=f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
@@ -54,6 +214,23 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(t->f.service.create(f.actor,f.create("cancel-history",List.of(source))));var root=f.repository.find(created.dispatchNo());
|
||||
var outbound=new com.baiyue.lastleg.delivery.entity.OutboundOrder();outbound.setId(123L);outbound.setCustomerId("101");outbound.setOutboundNo("OUT-HISTORY");outbound.setDispatchOrderId(root.getId());outbound.setStatus("WAIT_OUTBOUND");outbound.setPackageCount(3);outbound.setPalletCount(1);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
|
||||
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-history-original","取消未执行排车");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("关联出库");
|
||||
f.db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=123");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isOne();assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
|
||||
}
|
||||
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
|
||||
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划");
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
|
||||
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
|
||||
assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
}
|
||||
/** 作业读取返回真实明细身份,路线保存重排实际站序且保留起运仓。 */
|
||||
@Test void operationItemsAndRouteUseActualIdsAndPersistedStopOrder() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -223,7 +400,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
var mapperTypes=List.of(DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchExecutionReadMapper.class,DispatchCommandMapper.class,DispatchSourceMapper.class,
|
||||
DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,FleetResourceMapper.class,
|
||||
OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class);
|
||||
OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class);
|
||||
mapperTypes.forEach(config::addMapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
session=new SqlSessionTemplate(factory.getObject());
|
||||
@@ -233,19 +410,19 @@ public class DispatchAggregatePersistenceTest {
|
||||
heads.add(column+" "+type(field.getType())+(column.equals("id")?" PRIMARY KEY":""));
|
||||
}
|
||||
db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",heads)+",UNIQUE(dispatch_no),UNIQUE(trip_id))");
|
||||
for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class))
|
||||
for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class))
|
||||
for(var parent:mapper.getGenericInterfaces()) if(parent instanceof java.lang.reflect.ParameterizedType generic && generic.getRawType()==com.baomidou.mybatisplus.core.mapper.BaseMapper.class) {
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)generic.getActualTypeArguments()[0]); var columns=new ArrayList<String>(); columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:table.getFieldList()) if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !Set.of("customer_id","review_json").contains(field.getColumn())) columns.add(field.getColumn()+" "+type(field.getPropertyType()));
|
||||
db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD active_warehouse_pallet_id BIGINT UNIQUE");
|
||||
db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
|
||||
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)");
|
||||
@@ -273,10 +450,16 @@ public class DispatchAggregatePersistenceTest {
|
||||
fleet=new FleetTripExecutionService(session.getMapper(FleetTripMapper.class),session.getMapper(FleetTripDispatchMapper.class),session.getMapper(FleetResourceMapper.class),gateway,fleetService,drivers,vehicles,json,reservations);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fleet,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
var rules=mock(DispatchService.class);
|
||||
var emptySnapshot=new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"EMPTY",true,List.of());
|
||||
when(rules.lockExecutionSources(anyString(),anyList())).thenReturn(emptySnapshot);
|
||||
when(rules.lockWholeSources(eq("9"),anyList())).thenAnswer(invocation -> {
|
||||
List<DispatchCommands.CargoSelection> request=invocation.getArgument(1);
|
||||
return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList();
|
||||
});
|
||||
when(rules.lockAdditionalSources(eq("9"),anyList(),anyList())).thenAnswer(invocation -> {
|
||||
List<DispatchCommands.CargoSelection> request=invocation.getArgument(1);
|
||||
return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList();
|
||||
});
|
||||
service=new DispatchAggregateService(repository,
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
@@ -285,6 +468,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"executionReads",executionReads);
|
||||
var origins=new DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"sourceOrigins",origins);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"relabelSources",new DispatchItemSourceService(origins,mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class),json));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"physicalClaims",new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class))));
|
||||
}
|
||||
|
||||
/** 提供者固定事实代表真实客户来源,调用请求自身没有客户字段。 */
|
||||
@@ -322,7 +509,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
|
||||
db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,?,?,?,?,NULL,NULL,?,?)",source,customer,source,9000+source,source,packages,packages*2);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2);
|
||||
}
|
||||
|
||||
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
|
||||
|
||||
+10
@@ -29,6 +29,16 @@ class DispatchAppointmentAllocationsTest {
|
||||
verify(provider).lockBatches(new TreeMap<>(Map.of("FBA_APPOINTMENT:9",4L,"FBA_APPOINTMENT:2",0L)));
|
||||
verifyNoMoreInteractions(provider);
|
||||
}
|
||||
@Test void bulkCandidatesUseEveryRealOwnerOnceAndRequireACompleteAuthorityResponse() {
|
||||
var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider);
|
||||
var a=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("101").planPalletId(11L);
|
||||
var b=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("102").planPalletId(12L);
|
||||
var identities=List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",11L),new AppointmentWorkDispatchPort.CandidatePhysical("102",12L));
|
||||
var references=Map.of(identities.get(0),new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:7","AP-7",0L,"101",List.of(new AppointmentWorkDispatchPort.BatchSource("ORDER:1","FBA:ONT8",3)))),List.of(),false),identities.get(1),new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("原批次待确认"),false));
|
||||
when(provider.candidateBatches(identities)).thenReturn(references);
|
||||
assertThat(service.candidates(List.of(a,b,a))).isEqualTo(references);verify(provider,times(1)).candidateBatches(identities);
|
||||
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
|
||||
}
|
||||
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
|
||||
}
|
||||
|
||||
+38
-48
@@ -1,57 +1,47 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
/** 主单内部桥接使用实际主单、明细、资源、占用、命令和历史事务;Provider SQL由其直接测试覆盖。 */
|
||||
class DispatchAppointmentCancellationPersistenceTest {
|
||||
/** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */
|
||||
@Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class);
|
||||
mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject());
|
||||
for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) {
|
||||
if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue;
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)p.getActualTypeArguments()[0]);var columns=new ArrayList<String>();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
@Test void removingOneCustomersOriginalCargoKeepsTruckAndSiblingAndRecoversTheOriginalCommand() throws Exception {
|
||||
var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"appointment-remove-original","原预约整票移出");
|
||||
var removed=s.f.tx.execute(tx->{s.f.service.lockExistingAppointmentDispatches(s.f.actor,List.of(s.created.dispatchNo()));return s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true);});
|
||||
assertThat(removed.status()).isEqualTo(DispatchTypes.Status.ACTIVE);assertThat(removed.counts().packageCount()).isEqualTo(5);
|
||||
assertThat(s.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_item",String.class)).isEqualTo("102");
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();assertThat(s.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=502",Long.class)).isOne();
|
||||
long history=s.count("ll_dispatch_history");assertThat(s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true)).replayed()).isTrue();assertThat(s.count("ll_dispatch_history")).isEqualTo(history);
|
||||
verify(s.provider,times(1)).removeItem(s.root,s.item);
|
||||
}
|
||||
@Test void parentFailureRollsBackCargoClaimsReviewsHistoryAndChildLedger() throws Exception {
|
||||
var s=new Scenario();long commands=s.count("ll_dispatch_command"),history=s.count("ll_dispatch_history");
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->{s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"parent-rollback","原预约回滚"),s.physical(),s.expected(),List.of(),true);throw new IllegalStateException("parent rejected");})).hasMessageContaining("parent rejected");
|
||||
assertThat(s.count("ll_dispatch_item")).isEqualTo(2);assertThat(s.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Long.class)).isEqualTo(8);assertThat(s.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(s.created.recordVersion());assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);
|
||||
assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void wrongOwnerPartialQuantityOrChangedOriginalAllocationCannotMutateAnyCargo() throws Exception {
|
||||
var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-wrong-original","范围核验");
|
||||
for(var physical:List.of(List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"102",1L,"RECEIVED",3)),List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"101",1L,"RECEIVED",2))))assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,physical,s.expected(),List.of(),true))).hasMessageContaining("完整数量");
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),List.of(),List.of(),true))).hasMessageContaining("分配集合");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider);
|
||||
}
|
||||
@Test void realPickBlocksAppointmentRemovalEvenWhenMainCountersAreZero() throws Exception {
|
||||
var s=new Scenario();s.f.picked(1,"101",501,1);
|
||||
assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-picked","真实扫码"),s.physical(),s.expected(),List.of(),true))).hasMessageContaining("实际扫码");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider);
|
||||
}
|
||||
private static final class Scenario {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();final DispatchContracts.CommandResult created;final long root,item;final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class);
|
||||
Scenario() throws Exception {
|
||||
created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-main",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));root=f.repository.find(created.dispatchNo()).getId();item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(900,0,'AP-ORIGINAL')");f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",root,item);
|
||||
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
}
|
||||
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)");
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')");
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
|
||||
var allocations=session.getMapper(AppointmentWorkDispatchMapper.class);
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
|
||||
var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
|
||||
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')");
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)");
|
||||
tx.executeWithoutResult(s->service.cancel("DP-OLD"));
|
||||
assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4);
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull();
|
||||
List<DispatchAppointmentAmendmentPort.PhysicalItem> physical(){return List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(item,"101",1L,"RECEIVED",3));}
|
||||
List<DispatchContracts.BatchAllocation> expected(){return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:900","101","ORDER:9001","FBA:ONT8",3,0L));}
|
||||
long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
|
||||
}
|
||||
/** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";}
|
||||
}
|
||||
|
||||
+27
-19
@@ -9,7 +9,8 @@ import static org.mockito.Mockito.when;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
|
||||
import com.baiyue.lastleg.client.PrintPdfClient;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
import java.util.Map;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -26,7 +27,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchBolPrintServiceTest {
|
||||
@Mock private DispatchService dispatches;
|
||||
@Mock private DispatchBolReadService dispatches;
|
||||
@Mock private PrintPdfClient print;
|
||||
@Mock private DomesticWarehouseDirectoryClient warehouses;
|
||||
@InjectMocks private DispatchBolPrintService service;
|
||||
@@ -50,17 +51,13 @@ class DispatchBolPrintServiceTest {
|
||||
@Test
|
||||
void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() {
|
||||
String warehouseId = "193000000000200003";
|
||||
DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906",
|
||||
null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null,
|
||||
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
|
||||
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
|
||||
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
|
||||
"NYEXX2609110214", null, null, 1)), null, List.of(), null);
|
||||
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null,
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of());
|
||||
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
|
||||
null, "91730", "10767 7th Street", null);
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
|
||||
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
|
||||
when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
|
||||
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
||||
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address))));
|
||||
when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any()))
|
||||
@@ -79,18 +76,11 @@ class DispatchBolPrintServiceTest {
|
||||
@Test
|
||||
void createsOneBolDocumentPerFbaWarehouse() {
|
||||
String warehouseId = "193000000000200003";
|
||||
DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906",
|
||||
null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null,
|
||||
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
|
||||
2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
|
||||
List.of(
|
||||
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
|
||||
"NYEXX2609110214", null, null, 1),
|
||||
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
|
||||
"NYEXX2609110215", null, null, 1)), null, List.of(), null);
|
||||
DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null,
|
||||
LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of());
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
|
||||
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
|
||||
when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
|
||||
when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token"))
|
||||
.thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of())));
|
||||
when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any()))
|
||||
@@ -105,4 +95,22 @@ class DispatchBolPrintServiceTest {
|
||||
.map(PrintPdfClient.Document::fbaWarehouseCode).toList())
|
||||
.containsExactly("ABE2", "ONT8");
|
||||
}
|
||||
@Test
|
||||
void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() {
|
||||
var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5);
|
||||
var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second),
|
||||
Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B"));
|
||||
when(dispatches.read(detail.dispatchNo())).thenReturn(detail);
|
||||
var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null);
|
||||
when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of())));
|
||||
when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII)));
|
||||
service.generate(detail.dispatchNo(),"two-customers");
|
||||
var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
||||
verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture());
|
||||
assertThat(request.getValue().documents()).hasSize(2);
|
||||
assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B");
|
||||
}
|
||||
private static DispatchBolData.Line line(String customer,String code,String name,int packages) {
|
||||
return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages);
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class DispatchBolReadPersistenceTest {
|
||||
@Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception {
|
||||
var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')");
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)");
|
||||
f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')");
|
||||
f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5);
|
||||
var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B");
|
||||
assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey());
|
||||
}
|
||||
@Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception {
|
||||
var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)");
|
||||
f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2);
|
||||
var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");});
|
||||
}
|
||||
private static final class Fixture {
|
||||
final JdbcTemplate db;final DispatchBolReadService service;
|
||||
Fixture() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))");
|
||||
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class);
|
||||
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper());
|
||||
}
|
||||
void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);}
|
||||
}
|
||||
}
|
||||
+290
-15
@@ -10,10 +10,16 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -21,6 +27,7 @@ import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
@@ -45,11 +52,17 @@ class DispatchDraftPersistenceTest {
|
||||
private final AppointmentWorkDispatchPort appointmentProvider=mock(AppointmentWorkDispatchPort.class);
|
||||
private final Map<Long,String> versions=new ConcurrentHashMap<>();
|
||||
private final Map<Long,Long> physical=new ConcurrentHashMap<>();
|
||||
private final Map<Long,List<Long>> completeGroups=new ConcurrentHashMap<>();
|
||||
private final Map<Long,DispatchCommands.CargoSelection> choices=new ConcurrentHashMap<>();
|
||||
private final List<String> calls=Collections.synchronizedList(new ArrayList<>());
|
||||
private DriverManagerDataSource dataSource;
|
||||
private JdbcTemplate db;
|
||||
private DispatchDraftRepository repository;
|
||||
private DispatchDraftService service;
|
||||
private DispatchPhysicalClaimService physicalClaims;
|
||||
private PhysicalPalletSerialPort palletSerial;
|
||||
private DispatchSourceMapper actualClaimMapper;
|
||||
private TransactionTemplate transactions;
|
||||
private Clock clock=Clock.systemUTC();
|
||||
private Runnable beforeSourceLocks=()->{ };
|
||||
private final AtomicInteger commands=new AtomicInteger();
|
||||
@@ -61,35 +74,45 @@ class DispatchDraftPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_dispatch_draft(id BIGINT PRIMARY KEY,draft_token VARCHAR(128) UNIQUE,operator_id VARCHAR(128),warehouse_id VARCHAR(64),record_version BIGINT,sources_json CLOB,plan_ids_json CLOB,containers_json CLOB,status VARCHAR(32),expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_draft_container(id BIGINT PRIMARY KEY,draft_id BIGINT,draft_token VARCHAR(128),sequence_no INT,capacity INT,source_keys_json CLOB,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(draft_id,sequence_no))");
|
||||
db.execute("CREATE TABLE ll_dispatch_draft_command(id BIGINT PRIMARY KEY,idempotency_key VARCHAR(128) UNIQUE,operator_id VARCHAR(128),action VARCHAR(32),target_token VARCHAR(128),request_hash CHAR(64),result_json CLOB,created_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,active_warehouse_pallet_id BIGINT UNIQUE,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,physical_lock_id BIGINT,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))");
|
||||
db.execute("CREATE TABLE ll_dispatch_physical_pallet_lock(id BIGINT PRIMARY KEY,warehouse_id VARCHAR(128),warehouse_pallet_id BIGINT NOT NULL,active_warehouse_pallet_id BIGINT UNIQUE,dispatch_order_id BIGINT,draft_token VARCHAR(128),draft_container_id BIGINT,operator_id VARCHAR(128),status VARCHAR(32),expires_at TIMESTAMP,locked_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE draft_created_dispatch(dispatch_no VARCHAR(128) PRIMARY KEY,cargo_count INT)");
|
||||
db.execute("CREATE TABLE draft_source_gate(id BIGINT PRIMARY KEY)");
|
||||
db.execute("CREATE TABLE ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class))config.addMapper(mapper);
|
||||
for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class,DispatchPhysicalClaimMapper.class,PhysicalPalletSerialMapper.class,DispatchSourceMapper.class))config.addMapper(mapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(dataSource);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
repository=new DispatchDraftRepository(session.getMapper(DispatchDraftMapper.class),session.getMapper(DispatchDraftContainerMapper.class),
|
||||
session.getMapper(DispatchDraftCommandMapper.class),session.getMapper(DispatchDraftClaimMapper.class));
|
||||
var physicalTarget=new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class)));
|
||||
var physicalProxy=new ProxyFactory(physicalTarget);physicalProxy.setProxyTargetClass(true);
|
||||
physicalProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource()));
|
||||
physicalClaims=(DispatchPhysicalClaimService)physicalProxy.getProxy();
|
||||
var serialTarget=new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(session.getMapper(PhysicalPalletSerialMapper.class)));
|
||||
var serialProxy=new ProxyFactory(serialTarget);serialProxy.setProxyTargetClass(true);
|
||||
serialProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource()));
|
||||
palletSerial=(PhysicalPalletSerialPort)serialProxy.getProxy();
|
||||
actualClaimMapper=session.getMapper(DispatchSourceMapper.class);
|
||||
transactions=new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||
service=proxy();
|
||||
for(long id=1;id<=40;id++){versions.put(id,"version-"+id);physical.put(id,1000+id);db.update("INSERT INTO draft_source_gate VALUES(?)",id);}
|
||||
when(sources.selectionGroup(anyString(),any())).thenAnswer(call->group(call.getArgument(1)));
|
||||
when(sources.lockDraftSources(anyString(),anyList())).thenAnswer(call->{
|
||||
calls.add("sources");String warehouse=call.getArgument(0);List<DispatchCommands.CargoSelection> selections=call.getArgument(1);
|
||||
beforeSourceLocks.run();
|
||||
var result=new ArrayList<DispatchService.WholeSource>();
|
||||
for(var s:selections){long sourceId=s.planPalletId()!=null?s.planPalletId():s.returnReceiptId()!=null?s.returnReceiptId():s.warehousePalletId()-1000;
|
||||
var result=new ArrayList<DispatchService.WholeSource>();var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(java.util.stream.Collectors.toSet());
|
||||
for(var s:selections){long sourceId=sourceId(s);
|
||||
if(!selectedKeys.containsAll(group(s).sources().stream().map(SelectionGroupSource::sourceKey).toList()))throw new IllegalStateException("真实完整组缺少成员");
|
||||
db.queryForObject("SELECT id FROM draft_source_gate WHERE id=? FOR UPDATE",Long.class,sourceId);
|
||||
if(!Objects.equals(s.sourceVersion(),versions.get(sourceId)))throw new IllegalStateException("真实来源版本已变化");
|
||||
if(!"92".equals(warehouse))throw new IllegalStateException("真实来源仓不一致");
|
||||
var row=new DispatchPalletCandidate().planPalletId(sourceId).warehousePalletId(physical.get(sourceId))
|
||||
.returnReceiptId(s.sourceType()==SourceType.RETURN_RECEIPT?sourceId:null)
|
||||
.customerId(sourceId%2==0?"102":"101").customerName("客户"+sourceId).overseasWarehouseId("92")
|
||||
.overseasWarehouseCode("WH").overseasWarehouseName("海外仓").packageCount(10).palletNo("P-"+sourceId)
|
||||
.sourceStage(s.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType("FBA").destinationWarehouseCode(sourceId%2==0?"LAX9":"ONT8")
|
||||
.fbaWarehouseCode(sourceId%2==0?"LAX9":"ONT8");
|
||||
result.add(new DispatchService.WholeSource(row,s,DispatchService.wholeSourceKey(s),s.sourceVersion()));}
|
||||
result.add(whole(s));}
|
||||
// 受控来源入口仍遵循真实生产顺序:先完整来源身份锁,再共同实托串行点。
|
||||
palletSerial.lock(selections.stream().map(DispatchCommands.CargoSelection::warehousePalletId).filter(Objects::nonNull).distinct().sorted().toList());
|
||||
return result;
|
||||
});
|
||||
when(sourceClaims.claims(anyString(),anyString(),any(),any())).thenReturn(List.of());
|
||||
when(sourceClaims.claims(anyString(),anyString(),any())).thenReturn(List.of());
|
||||
when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{List<AppointmentWorkDispatchPort.CandidatePhysical> items=call.getArgument(0);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of(),false));return result;});
|
||||
doAnswer(call->{calls.add("resources");return null;}).when(aggregate).lockDraftCreationResources(any(),anyList());
|
||||
when(aggregate.create(any(),any())).thenAnswer(call->{calls.add("create");DispatchCommands.Create request=call.getArgument(1);String no="DS-"+request.idempotencyKey();
|
||||
@@ -139,6 +162,184 @@ class DispatchDraftPersistenceTest {
|
||||
assertThat(state.containers().get(0).sourceKeys()).containsExactly("RETURN:1","RELABEL:RL-2:1002");
|
||||
}
|
||||
|
||||
/** 单来源请求一次加入同托完整两申请,客户与逻辑箱数独立但共享唯一物理占用。 */
|
||||
@Test void oneSelectionAtomicallyClaimsWholeRelabelGroupForMultipleCustomers(){
|
||||
relabelPair();var before=empty(1);String commandKey=key();
|
||||
var request=new DraftSelection(before.recordVersion(),commandKey,before.draftToken(),before.containers().get(0).draftContainerId(),choices.get(1L));
|
||||
var state=service.select(owner,request);
|
||||
assertThat(state.recordVersion()).isEqualTo(before.recordVersion()+1);
|
||||
assertThat(state.sources()).extracting(Candidate::customerId).containsExactly("101","102");
|
||||
assertThat(state.sources()).extracting(Candidate::sourceKey).containsExactly("RELABEL:RL-1:1001","RELABEL:RL-2:1001");
|
||||
assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(20);
|
||||
assertThat(state.sources()).allSatisfy(candidate->assertThat(candidate.selectionGroup().sources()).hasSize(2));
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
assertThat(proxy().select(owner,request)).isEqualTo(state);
|
||||
verify(sources).lockDraftSources("92",List.of(choices.get(1L),choices.get(2L)));
|
||||
}
|
||||
|
||||
/** 从组内任一成员解除时,同一命令结束全组逻辑来源并在最后成员后释放原物理占用。 */
|
||||
@Test void oneUnselectReleasesEveryFrozenMemberAndItsLastPhysicalClaim(){
|
||||
relabelPair();var state=select(empty(1),0,1);var before=state;
|
||||
var request=new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(2L));
|
||||
versions.put(1L,"changed-after-freeze");
|
||||
var released=service.unselect(owner,request);
|
||||
assertThat(released.recordVersion()).isEqualTo(before.recordVersion()+1);
|
||||
assertThat(released.sources()).isEmpty();assertThat(released.containers().get(0).sourceKeys()).isEmpty();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED' AND active_warehouse_pallet_id IS NULL",Integer.class)).isEqualTo(1);
|
||||
assertThat(proxy().unselect(owner,request)).isEqualTo(released);
|
||||
}
|
||||
|
||||
/** 完整组缺少锁内成员或版本改变时不能留下部分来源、物理占用、命令结果或根版本。 */
|
||||
@Test void missingLockedMemberAndStaleGroupVersionRollBackAtomicSelection(){
|
||||
relabelPair();var state=empty(1);String failedKey=key();
|
||||
doReturn(List.of(whole(choices.get(1L)))).when(sources).lockDraftSources(eq("92"),anyList());
|
||||
assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),failedKey,state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L))))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(state);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero();
|
||||
versions.put(1L,"new-version");
|
||||
assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L))))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(state);
|
||||
}
|
||||
|
||||
/** 完整组即使跨两个实托也不能布局拆开;整组移动同步逻辑成员及全部物理拥有者。 */
|
||||
@Test void wholeGroupCannotSplitButCanMoveAllPhysicalPalletsToAnotherContainer(){
|
||||
completeGroups.put(1L,List.of(1L,2L));completeGroups.put(2L,List.of(1L,2L));
|
||||
var state=select(empty(2),0,1);var original=state;var a=state.containers().get(0);var b=state.containers().get(1);
|
||||
assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),key(),original.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:1")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:2"))))))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(original);
|
||||
state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),1,15,List.of()),new DraftContainer(b.draftContainerId(),2,15,a.sourceKeys()))));
|
||||
assertThat(state.recordVersion()).isEqualTo(original.recordVersion()+1);
|
||||
assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class))
|
||||
.containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId());
|
||||
assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class))
|
||||
.containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId());
|
||||
}
|
||||
|
||||
/** 一个原容器内独立完整组可以部分移走,原地实托及其客户来源不应被桥接带走。 */
|
||||
@Test void movingOneCompleteGroupKeepsOtherPhysicalPalletInOriginalContainer(){
|
||||
var state=select(empty(2),0,1);state=select(state,0,2);var a=state.containers().get(0);var b=state.containers().get(1);
|
||||
state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:2")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:1")))));
|
||||
assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1001",Long.class)).isEqualTo(b.draftContainerId());
|
||||
assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1002",Long.class)).isEqualTo(a.draftContainerId());
|
||||
assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:2'",Long.class)).isEqualTo(a.draftContainerId());
|
||||
assertThat(state.sources()).hasSize(2);
|
||||
}
|
||||
|
||||
/** 还没有真实实托的计划独立计位,不制造物理占用记录。 */
|
||||
@Test void unmaterializedPlanKeepsLogicalIdentityWithoutSyntheticPhysicalClaim(){
|
||||
physical.remove(1L);var state=select(empty(1),0,1);
|
||||
assertThat(state.sources()).hasSize(1);assertThat(state.sources().get(0).selection().warehousePalletId()).isNull();
|
||||
assertThat(state.sources().get(0).selectionGroup().pallets().get(0).warehousePalletId()).isNull();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:1' AND physical_lock_id IS NULL",Integer.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 十六条逻辑来源共享一个实托仍只占一个容量位,箱数按每条真实贡献累计。 */
|
||||
@Test void capacityCountsOnePhysicalPalletAndAllLogicalPackages(){
|
||||
var members=java.util.stream.LongStream.rangeClosed(1,16).boxed().toList();
|
||||
for(var id:members){physical.put(id,1001L);completeGroups.put(id,members);}
|
||||
var state=select(empty(1),0,1);
|
||||
assertThat(state.containers().get(0).capacity()).isEqualTo(15);
|
||||
assertThat(state.containers().get(0).sourceKeys()).hasSize(16);
|
||||
assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(160);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(16);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 到期实托由共同提供者结束旧组成员后才能由新草稿完整领取,不复活原拥有者。 */
|
||||
@Test void expiryReclaimsWholePhysicalGroupWithoutTouchingNewOwner(){
|
||||
relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy();
|
||||
var original=select(empty(1),0,1);controlled.set(started.plus(Duration.ofMinutes(31)));
|
||||
var next=service.createDraft(other,new CreateDraft(key(),"92"));next=service.createContainer(other,new CreateContainer(0L,key(),next.draftToken(),15));
|
||||
next=service.select(other,new DraftSelection(next.recordVersion(),key(),next.draftToken(),next.containers().get(0).draftContainerId(),choices.get(1L)));
|
||||
assertThat(next.sources()).hasSize(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='ACTIVE'",Integer.class,next.draftToken())).isEqualTo(1);
|
||||
assertThatThrownBy(()->service.release(owner,new DraftRelease(original.recordVersion(),key(),original.draftToken()))).isInstanceOf(BizException.class);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 纯释放或续约先在真实 Serial SQL 等待,接管事务可结束到期成员;旧拥有者不能复活租约或释放新拥有者。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER"})
|
||||
@Timeout(30)
|
||||
void pureDraftActionsWaitAtSerialBeforeLogicalClaimsDuringExpiredTakeover(String action)throws Exception {
|
||||
relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy();
|
||||
var draft=select(empty(2),0,1);var oldOwner=new DispatchPhysicalClaimService.Owner("92",null,draft.draftToken(),
|
||||
draft.containers().get(0).draftContainerId(),owner.operatorId(),LocalDateTime.ofInstant(draft.expiresAt(),ZoneOffset.UTC));
|
||||
long oldPhysical=db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class);
|
||||
controlled.set(started.plus(Duration.ofMinutes(30)).minusMillis(1));
|
||||
versions.put(1L,"changed-source-must-not-be-refreshed");clearInvocations(sources);
|
||||
var holderReady=new CountDownLatch(1);var allowTakeover=new CountDownLatch(1);var waiterReady=new CountDownLatch(1);
|
||||
var holderSession=new AtomicInteger();var waiterSession=new AtomicInteger();String failedKey="guard-crossing-"+action;
|
||||
var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Long> takeover=pool.submit(()->transactions.execute(status->{
|
||||
palletSerial.lock(List.of(1001L));
|
||||
physicalClaims.lockOwner(oldOwner);
|
||||
holderSession.set(sessionId());holderReady.countDown();awaitLatch(allowTakeover);
|
||||
var at=LocalDateTime.ofInstant(controlled.instant(),ZoneOffset.UTC);
|
||||
var acquired=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner("92",9000L,null,null,"new-owner",null),List.of(1001L),at);
|
||||
long physicalId=acquired.get(1001L);long id=9001;
|
||||
for(var source:draft.sources())assertThat(actualClaimMapper.claim(id++,source.customerId(),source.sourceKey(),
|
||||
source.selection().planPalletId(),source.selection().warehousePalletId(),source.selection().returnReceiptId(),physicalId,9000,"new-owner",at)).isEqualTo(1);
|
||||
return physicalId;
|
||||
}));
|
||||
awaitLatch(holderReady);
|
||||
Future<Draft> pending=pool.submit(()->transactions.execute(status->{
|
||||
waiterSession.set(sessionId());waiterReady.countDown();return pureChange(action,draft,failedKey);
|
||||
}));
|
||||
awaitLatch(waiterReady);
|
||||
awaitActualGuardWait(waiterSession.get(),holderSession.get(),pending);
|
||||
controlled.set(started.plus(Duration.ofMinutes(30)).plusMillis(1));allowTakeover.countDown();
|
||||
long newPhysical=takeover.get(10,TimeUnit.SECONDS);
|
||||
assertThatThrownBy(()->pending.get(10,TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class).hasCauseInstanceOf(BizException.class);
|
||||
assertThat(newPhysical).isNotEqualTo(oldPhysical);
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE id=?",String.class,oldPhysical)).isEqualTo("EXPIRED");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,draft.draftToken())).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(9000L);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE dispatch_order_id=9000 AND physical_lock_id=? AND status='ACTIVE'",Integer.class,newPhysical)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT record_version FROM ll_dispatch_draft WHERE draft_token=?",Long.class,draft.draftToken())).isEqualTo(draft.recordVersion());
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_container WHERE draft_token=?",Integer.class,draft.draftToken())).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero();
|
||||
assertThatThrownBy(()->service.release(owner,new DraftRelease(draft.recordVersion(),"late-owner-release-"+action,draft.draftToken()))).isInstanceOf(BizException.class);
|
||||
assertThat(db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(newPhysical);
|
||||
verifyNoInteractions(sources);
|
||||
} finally {
|
||||
allowTakeover.countDown();pool.shutdownNow();assertThat(pool.awaitTermination(10,TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/** 共享实托完整组不能部分转换,成功转换和原键恢复保留全部客户及来源版本。 */
|
||||
@Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){
|
||||
relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch();
|
||||
var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(),
|
||||
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations());
|
||||
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial)))))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
|
||||
var converted=service.convert(owner,full);assertThat(converted.vehicles()).hasSize(1);
|
||||
var captured=org.mockito.ArgumentCaptor.forClass(DispatchCommands.Create.class);verify(aggregate).create(eq(owner),captured.capture());
|
||||
assertThat(captured.getValue().cargo()).containsExactlyElementsOf(state.sources().stream().map(Candidate::selection).toList());
|
||||
assertThat(db.queryForObject("SELECT cargo_count FROM draft_created_dispatch",Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(1);
|
||||
clearInvocations(aggregate,sources);
|
||||
assertThat(proxy().convert(owner,full).vehicles()).isEqualTo(converted.vehicles());verifyNoInteractions(aggregate,sources);
|
||||
}
|
||||
|
||||
/** 第二辆车失败必须撤销首车、草稿状态、命令及所有占用释放;原键重试成功后只恢复结果。 */
|
||||
@Test void laterVehicleFailureRollsBackWholeBatchAndUnknownSuccessReplaysAfterRestart(){
|
||||
var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3);
|
||||
@@ -148,6 +349,7 @@ class DispatchDraftPersistenceTest {
|
||||
assertThatThrownBy(()->service.convert(owner,request)).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(original);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='convert'",Integer.class)).isZero();
|
||||
calls.clear();doAnswer(call->{calls.add("create");DispatchCommands.Create r=call.getArgument(1);String no="DS-"+r.idempotencyKey();
|
||||
@@ -184,7 +386,8 @@ class DispatchDraftPersistenceTest {
|
||||
var release=new DraftRelease(state.recordVersion(),"release",state.draftToken());var released=service.release(owner,release);
|
||||
assertThat(released.status()).isEqualTo("RELEASED");assertThat(released.sources()).isEmpty();
|
||||
assertThat(proxy().release(owner,release)).isEqualTo(released);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL OR active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 过期和旧版本不能变更;布局容量按完整物理来源集合核对。 */
|
||||
@@ -267,16 +470,88 @@ class DispatchDraftPersistenceTest {
|
||||
start.await();try{service.select(actor,new DraftSelection(draft.recordVersion(),UUID.randomUUID().toString(),draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(id)));return true;}
|
||||
catch(BizException conflict){return false;}
|
||||
}
|
||||
/** 四种纯释放或租约入口只消费草稿原冻结来源,不请求当前来源资格。 */
|
||||
private Draft pureChange(String action,Draft draft,String commandKey) {
|
||||
return switch(action) {
|
||||
case "UNSELECT"->service.unselect(owner,new DraftSelection(draft.recordVersion(),commandKey,draft.draftToken(),draft.containers().get(0).draftContainerId(),draft.sources().get(0).selection()));
|
||||
case "RELEASE"->service.release(owner,new DraftRelease(draft.recordVersion(),commandKey,draft.draftToken()));
|
||||
case "CREATE_CONTAINER"->service.createContainer(owner,new CreateContainer(draft.recordVersion(),commandKey,draft.draftToken(),15));
|
||||
case "REMOVE_CONTAINER"->service.removeContainer(owner,draft.draftToken(),draft.containers().get(1).draftContainerId(),new DispatchCommands.ReasonCommand(draft.recordVersion(),commandKey,"移除空容器"));
|
||||
default->throw new IllegalArgumentException("未支持的测试动作");
|
||||
};
|
||||
}
|
||||
/** 数据库会话取自调用方当前事务连接,不以线程启动标记冒充 SQL 执行。 */
|
||||
private int sessionId(){return db.queryForObject("SELECT SESSION_ID()",Integer.class);}
|
||||
/** 有界协调真实事务阶段,异常时由调用方 finally 放开持锁者。 */
|
||||
private void awaitLatch(CountDownLatch latch) {
|
||||
try {if(!latch.await(10,TimeUnit.SECONDS))throw new IllegalStateException("等待真实事务阶段超时");}
|
||||
catch(InterruptedException failure){Thread.currentThread().interrupt();throw new IllegalStateException("测试事务已中断",failure);}
|
||||
}
|
||||
/** 必须实际看到 guard 写语句被指定物理拥有者会话阻塞,不能用 Future 超时判断数据库已等待。 */
|
||||
private void awaitActualGuardWait(int waiter,int holder,Future<?> pending)throws Exception {
|
||||
assertThat(waiter).isPositive().isNotEqualTo(holder);assertThat(holder).isPositive();
|
||||
long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);List<Map<String,Object>> last=List.of();
|
||||
while(System.nanoTime()<deadline) {
|
||||
if(pending.isDone()){pending.get();throw new AssertionError("持锁者未释放但旧草稿动作已完成");}
|
||||
last=db.queryForList("SELECT EXECUTING_STATEMENT,BLOCKER_ID FROM INFORMATION_SCHEMA.SESSIONS WHERE SESSION_ID=?",waiter);
|
||||
if(last.size()!=1)continue;
|
||||
var row=last.get(0);String sql=String.valueOf(row.get("EXECUTING_STATEMENT")).toUpperCase(Locale.ROOT);
|
||||
if(row.get("BLOCKER_ID") instanceof Number blocker && blocker.intValue()==holder && sql.contains("LL_PHYSICAL_PALLET_GUARD")
|
||||
&& (sql.contains("INSERT") || sql.contains("UPDATE"))){assertThat(pending.isDone()).isFalse();return;}
|
||||
}
|
||||
throw new AssertionError("未观察到指定会话在共同 Serial SQL 等待,waiter="+waiter+",holder="+holder+",sessions="+last);
|
||||
}
|
||||
/** 新服务实例和ObjectMapper模拟业务进程内缓存消失,事务代理沿真实注解。 */
|
||||
private DispatchDraftService proxy(){var json=new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
var target=new DispatchDraftService(repository,sourceClaims,sources,aggregate,new DispatchCommandHasher(json),json,new DispatchCommandCodec(json),appointmentProvider,clock);var factory=new ProxyFactory(target);factory.setProxyTargetClass(true);
|
||||
var target=new DispatchDraftService(repository,sourceClaims,sources,aggregate,new DispatchCommandHasher(json),json,new DispatchCommandCodec(json),appointmentProvider,physicalClaims,palletSerial,clock);var factory=new ProxyFactory(target);factory.setProxyTargetClass(true);
|
||||
factory.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource()));return (DispatchDraftService)factory.getProxy();}
|
||||
/** 创建指定数量空容器,每次使用服务端返回版本。 */
|
||||
private Draft empty(int containers){var state=service.createDraft(owner,new CreateDraft(key(),"92"));for(int i=0;i<containers;i++)state=service.createContainer(owner,new CreateContainer(state.recordVersion(),key(),state.draftToken(),15));return state;}
|
||||
/** 选择请求使用候选提供者真实版本和物理身份。 */
|
||||
private Draft select(Draft draft,int container,long id){return service.select(owner,new DraftSelection(draft.recordVersion(),key(),draft.draftToken(),draft.containers().get(container).draftContainerId(),selection(id)));}
|
||||
private Draft select(Draft draft,int container,long id){return service.select(owner,new DraftSelection(draft.recordVersion(),key(),draft.draftToken(),draft.containers().get(container).draftContainerId(),choices.getOrDefault(id,selection(id))));}
|
||||
/** 只构造普通来源边界输入;生产资格仍由真实DispatchService提供。 */
|
||||
private DispatchCommands.CargoSelection selection(long id){return new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,id,null,null,physical.get(id),versions.get(id));}
|
||||
/** 两张真实客户申请共享实托,完整组在两个入口下都返回相同成员。 */
|
||||
private void relabelPair() {
|
||||
physical.put(2L,1001L);completeGroups.put(1L,List.of(1L,2L));completeGroups.put(2L,List.of(1L,2L));
|
||||
choices.put(1L,new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-1",1001L,versions.get(1L)));
|
||||
choices.put(2L,new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-2",1001L,versions.get(2L)));
|
||||
}
|
||||
/** 同一实托使用一致目的仓;实际锁、版本及完整组由上层受控提供者入口核对。 */
|
||||
private DispatchService.WholeSource whole(DispatchCommands.CargoSelection selection) {
|
||||
long sourceId=sourceId(selection);Long pallet=physical.get(sourceId);String destination=(pallet==null?sourceId:pallet)%2==0?"LAX9":"ONT8";
|
||||
var row=new DispatchPalletCandidate().planPalletId(sourceId).warehousePalletId(pallet)
|
||||
.returnReceiptId(selection.sourceType()==SourceType.RETURN_RECEIPT?sourceId:null)
|
||||
.customerId(sourceId%2==0?"102":"101").customerName("客户"+sourceId).overseasWarehouseId("92")
|
||||
.overseasWarehouseCode("WH").overseasWarehouseName("海外仓").packageCount(10).palletNo("P-"+pallet)
|
||||
.sourceStage(selection.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType("FBA")
|
||||
.destinationWarehouseCode(destination).fbaWarehouseCode(destination);
|
||||
return new DispatchService.WholeSource(row,selection,DispatchService.wholeSourceKey(selection),selection.sourceVersion());
|
||||
}
|
||||
/** 受控事实按原来源类型及服务器版本产生完整组,不把组装责任交给前端。 */
|
||||
private SelectionGroup group(DispatchCommands.CargoSelection requested) {
|
||||
long seed=sourceId(requested);var members=new ArrayList<SelectionGroupSource>();
|
||||
for(long id:completeGroups.getOrDefault(seed,List.of(seed))) {
|
||||
var input=choices.getOrDefault(id,id==seed?requested:selection(id));
|
||||
var choice=new DispatchCommands.CargoSelection(input.sourceType(),input.planPalletId(),input.returnReceiptId(),input.relabelNo(),physical.get(id),versions.get(id));
|
||||
String key=DispatchService.wholeSourceKey(choice);
|
||||
members.add(new SelectionGroupSource(choice,key,id%2==0?"102":"101","客户"+id,
|
||||
choice.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":choice.sourceType().name(),choice.relabelNo()==null?"PLAN-"+id:choice.relabelNo(),10));
|
||||
}
|
||||
var byPallet=new TreeMap<Long,List<SelectionGroupSource>>();
|
||||
for(var member:members)if(member.selection().warehousePalletId()!=null)byPallet.computeIfAbsent(member.selection().warehousePalletId(),ignored->new ArrayList<>()).add(member);
|
||||
var pallets=new ArrayList<>(byPallet.entrySet().stream().map(entry->new SelectionGroupPallet(entry.getKey(),"P-"+entry.getKey(),
|
||||
entry.getValue().stream().map(SelectionGroupSource::sourceKey).sorted().toList(),10L*entry.getValue().size())).toList());
|
||||
for(var member:members)if(member.selection().warehousePalletId()==null)pallets.add(new SelectionGroupPallet(null,null,List.of(member.sourceKey()),member.packages()));
|
||||
return new SelectionGroup("GROUP-"+members.stream().map(SelectionGroupSource::sourceKey).sorted().toList(),
|
||||
"GROUP-VERSION-"+members.stream().map(member->member.selection().sourceVersion()).toList(),List.copyOf(members),List.copyOf(pallets),true,List.of());
|
||||
}
|
||||
/** 合成来源业务号用于测试身份定位,同实托时也不能借物理号猜成其他申请。 */
|
||||
private long sourceId(DispatchCommands.CargoSelection source) {
|
||||
if(source.planPalletId()!=null)return source.planPalletId();
|
||||
if(source.returnReceiptId()!=null)return source.returnReceiptId();
|
||||
return Long.parseLong(source.relabelNo().substring("RL-".length()));
|
||||
}
|
||||
/** 每个非空服务器容器生成一组完整创建请求。 */
|
||||
private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null,
|
||||
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItemSource;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.IllegalTransactionStateException;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 受控来源 port 配合真实 Mapper、仓储、服务及事务;不代替真实换标 provider 的组合验收。 */
|
||||
class DispatchItemSourcePersistenceTest {
|
||||
private static final long DISPATCH = 77L;
|
||||
private static final long PALLET = 500L;
|
||||
private static final LocalDateTime AT = LocalDateTime.of(2026, 10, 19, 12, 0);
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
private final RelabelDispatchSourcePort provider = mock(RelabelDispatchSourcePort.class);
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private DispatchItemSourceRepository repository;
|
||||
private DispatchItemSourceService service;
|
||||
private DispatchAggregate root;
|
||||
private DispatchItem first;
|
||||
private DispatchItem second;
|
||||
private DispatchItem third;
|
||||
private List<DispatchItem> items;
|
||||
private Snapshot supplied;
|
||||
|
||||
/** 建立与正式贡献和拣货约束对应的隔离表,通过实际 Spring 代理加入调用方事务。 */
|
||||
@BeforeEach
|
||||
void prepare() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:item-source-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_item_source (
|
||||
id BIGINT PRIMARY KEY, dispatch_order_id BIGINT NOT NULL, dispatch_item_id BIGINT NOT NULL,
|
||||
customer_id VARCHAR(128) NOT NULL, relabel_application_id BIGINT NOT NULL,
|
||||
relabel_item_id BIGINT NOT NULL, relabel_round_key VARCHAR(128) NOT NULL, warehouse_pallet_id BIGINT NOT NULL,
|
||||
inventory_position_id BIGINT NOT NULL, inventory_balance_id BIGINT NOT NULL,
|
||||
source_inventory_balance_id BIGINT, source_inbound_order_id BIGINT,
|
||||
source_inbound_order_no VARCHAR(128), source_inbound_code VARCHAR(128),
|
||||
notified_package_count BIGINT NOT NULL, package_count BIGINT NOT NULL, product_quantity BIGINT,
|
||||
binding_basis_hash CHAR(64) NOT NULL, unit_facts_json CLOB NOT NULL, created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id),
|
||||
CHECK(notified_package_count>=0 AND package_count>0), CHECK(product_quantity IS NULL OR product_quantity>=0))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_pda_pick_order (
|
||||
id BIGINT PRIMARY KEY, customer_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL,
|
||||
receipt_id BIGINT, return_receipt_id BIGINT, return_carton_id BIGINT, dispatch_item_source_id BIGINT,
|
||||
order_id BIGINT NOT NULL, packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
CHECK((CASE WHEN receipt_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+(CASE WHEN return_receipt_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+(CASE WHEN return_carton_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+(CASE WHEN dispatch_item_source_id IS NOT NULL THEN 1 ELSE 0 END)=1))
|
||||
""");
|
||||
jdbc.execute("CREATE TABLE test_caller_transaction_marker(id INT PRIMARY KEY)");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(DispatchItemSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds);
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class);
|
||||
repository = new DispatchItemSourceRepository(mapper);
|
||||
var manager = new DataSourceTransactionManager(ds);
|
||||
transaction = new TransactionTemplate(manager);
|
||||
var target = new DispatchItemSourceService(repository, provider, json);
|
||||
var proxy = new ProxyFactory(target);
|
||||
proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
|
||||
service = (DispatchItemSourceService) proxy.getProxy();
|
||||
root = new DispatchAggregate();
|
||||
root.setId(DISPATCH); root.setOverseasWarehouseId("1001");
|
||||
first = item(11, "101", "RL-201", 1001, PALLET, 2);
|
||||
second = item(12, "101", "RL-202", 1002, PALLET, 3);
|
||||
third = item(13, "102", "RL-203", 1003, PALLET, 4);
|
||||
items = List.of(first, second, third);
|
||||
supplied = new Snapshot(List.of(application(201, "RL-201", "101", 1001),
|
||||
application(202, "RL-202", "101", 1002), application(203, "RL-203", "102", 1003)),
|
||||
List.of(source(201, "RL-201", 301, "101", PALLET, 101, 601, 501, 701, 2, 10L, "SHARED"),
|
||||
source(202, "RL-202", 302, "101", PALLET, 101, 601, 502, 702, 3, null, "SHARED"),
|
||||
source(203, "RL-203", 303, "102", PALLET, 102, 602, 503, 703, 4, 8L, "SHARED")),
|
||||
List.of(position(101, "101", 601, PALLET, 5), position(102, "102", 602, PALLET, 4)),
|
||||
"c".repeat(64), true, List.of());
|
||||
when(provider.lockedSnapshot(anyString(), anyList())).thenAnswer(invocation -> supplied);
|
||||
}
|
||||
|
||||
/** 同托两个申请和另一客户各存独立贡献,原入仓链及未知件数原样冻结。 */
|
||||
@Test
|
||||
void captureFreezesEveryApplicationCustomerAndOriginalSource() throws Exception {
|
||||
capture();
|
||||
var rows = repository.all(DISPATCH);
|
||||
assertEquals(3, rows.size());
|
||||
assertEquals(List.of(201L, 202L, 203L), rows.stream().map(DispatchItemSource::getRelabelApplicationId).toList());
|
||||
assertEquals(List.of("101", "101", "102"), rows.stream().map(DispatchItemSource::getCustomerId).toList());
|
||||
assertEquals(List.of(701L, 702L, 703L), rows.stream().map(DispatchItemSource::getSourceInboundOrderId).toList());
|
||||
assertEquals(List.of(501L, 502L, 503L), rows.stream().map(DispatchItemSource::getSourceInventoryBalanceId).toList());
|
||||
assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), rows.stream().map(DispatchItemSource::getRelabelRoundKey).toList());
|
||||
assertEquals(List.of("IN-701", "IN-702", "IN-703"), rows.stream().map(DispatchItemSource::getSourceInboundOrderNo).toList());
|
||||
assertEquals(List.of("CODE-701", "CODE-702", "CODE-703"), rows.stream().map(DispatchItemSource::getSourceInboundCode).toList());
|
||||
assertNull(rows.get(1).getProductQuantity());
|
||||
assertEquals(10L, rows.get(0).getProductQuantity());
|
||||
assertEquals(5, rows.stream().filter(s -> s.getInventoryPositionId() == 101).mapToLong(DispatchItemSource::getPackageCount).sum());
|
||||
for (var row : rows) {
|
||||
var original = supplied.sources().stream().filter(s -> s.itemId().equals(row.getRelabelItemId())).findFirst().orElseThrow();
|
||||
assertEquals(original.boundPackageCount(), row.getPackageCount().longValue());
|
||||
assertEquals(original.bindingBasisHash(), row.getBindingBasisHash());
|
||||
assertEquals(json.readTree(json.writeValueAsString(original.unitFacts())), json.readTree(row.getUnitFactsJson()));
|
||||
assertNotEquals(supplied.applications().stream().filter(a -> a.applicationId().equals(original.applicationId()))
|
||||
.findFirst().orElseThrow().fulfillmentOrderId(), row.getSourceInboundOrderId());
|
||||
}
|
||||
verify(provider).lockedSnapshot("1001", List.of(PALLET));
|
||||
verify(provider, never()).snapshot(anyString(), anyList());
|
||||
verify(provider, never()).lockApplications(anyList());
|
||||
}
|
||||
|
||||
/** 来源冻结和分配必须加入真实调用方事务,不能在事务外独立执行。 */
|
||||
@Test
|
||||
void captureAndAllocateRequireCallerTransaction() {
|
||||
assertThrows(IllegalTransactionStateException.class, () -> service.capture(root, items, items, AT));
|
||||
assertThrows(IllegalTransactionStateException.class,
|
||||
() -> service.allocate(DISPATCH, first, 101, 1, 0, null, List.of(stock(101, 601, 5))));
|
||||
assertEquals(0, countSources());
|
||||
}
|
||||
|
||||
/** 同实托遗漏另一申请时,已尝试保存的首申请和调用方其他写入必须一起回滚。 */
|
||||
@Test
|
||||
void missingApplicationOnPhysicalPalletRollsBackWholeCallerTransaction() {
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> {
|
||||
jdbc.update("INSERT INTO test_caller_transaction_marker VALUES(1)");
|
||||
service.capture(root, List.of(first, third), List.of(first, third), AT);
|
||||
}));
|
||||
assertEquals(0, countSources());
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM test_caller_transaction_marker", Integer.class));
|
||||
}
|
||||
|
||||
/** 同申请还有另一实托时,不能只冻结请求托盘上的部分明细。 */
|
||||
@Test
|
||||
void missingOtherPalletOfSameApplicationRollsBackAllContributions() {
|
||||
var sources = new ArrayList<>(supplied.sources());
|
||||
sources.add(source(201, "RL-201", 304, "101", 501, 103, 603, 504, 704, 1, null, "OTHER-PALLET"));
|
||||
var positions = new ArrayList<>(supplied.positions());
|
||||
positions.add(position(103, "101", 603, 501, 1));
|
||||
supplied = snapshot(sources, positions);
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)));
|
||||
assertEquals(0, countSources());
|
||||
}
|
||||
|
||||
/** 每张申请各自小于 HOLD 仍不能掩盖同位置合计超量。 */
|
||||
@Test
|
||||
void combinedApplicationsCannotExceedPositionHold() {
|
||||
supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 4),
|
||||
position(102, "102", 602, PALLET, 4)));
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)));
|
||||
assertEquals(0, countSources());
|
||||
}
|
||||
|
||||
/** 额外 HOLD 也说明实托成员未完整核齐,不能悄悄忽略未归属冻结箱数。 */
|
||||
@Test
|
||||
void unexplainedHoldCannotBeOmittedFromCompletePhysicalSelection() {
|
||||
supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 6),
|
||||
position(102, "102", 602, PALLET, 4)));
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)));
|
||||
assertEquals(0, countSources());
|
||||
}
|
||||
|
||||
/** 客户与履约订单必须匹配真实申请,不接受借原入仓订单替换履约归属。 */
|
||||
@Test
|
||||
void captureRejectsWrongCustomerAndWrongFulfillmentOwner() {
|
||||
second.setCustomerId("102");
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)));
|
||||
assertEquals(0, countSources());
|
||||
second.setCustomerId("101");
|
||||
first.setOrderId(701L);
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)));
|
||||
assertEquals(0, countSources());
|
||||
}
|
||||
|
||||
/** 相同贡献唯一约束阻止重复冻结,失败事务不能覆盖或删除已提交来源。 */
|
||||
@Test
|
||||
void duplicateCaptureLeavesOriginalImmutableRowsIntact() {
|
||||
capture();
|
||||
var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList();
|
||||
assertThrows(DataIntegrityViolationException.class,
|
||||
() -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT.plusSeconds(1))));
|
||||
assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList());
|
||||
assertEquals(3, countSources());
|
||||
}
|
||||
|
||||
/** 不新增明细时仍复核完整成员,但不能重写已冻结的原货源。 */
|
||||
@Test
|
||||
void recheckingExistingMembersDoesNotAppendOrReplaceSources() {
|
||||
capture();
|
||||
var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList();
|
||||
transaction.executeWithoutResult(status -> service.capture(root, items, List.of(), AT.plusSeconds(1)));
|
||||
assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList());
|
||||
}
|
||||
|
||||
/** 原轮次写入真实持久列后允许正常扫码状态,返工新轮次不能复用旧冻结来源。 */
|
||||
@Test
|
||||
void frozenRoundSurvivesNormalStateChangesAndRejectsReworkRound() {
|
||||
capture();
|
||||
var scheduled = supplied.applications().stream().map(app -> new Application(app.applicationId(), app.relabelNo(),
|
||||
app.customerId(), app.fulfillmentOrderId(), "READY_FOR_OUTBOUND", "PRE_DISPATCH_CREATED", "TRUCK",
|
||||
app.roundKey(), AT.plusSeconds(1), app.customerConfirmedAt())).toList();
|
||||
var current = new Snapshot(scheduled, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of());
|
||||
transaction.executeWithoutResult(status -> service.validateFrozen(root, items, current));
|
||||
var reworked = scheduled.stream().map(app -> new Application(app.applicationId(), app.relabelNo(), app.customerId(),
|
||||
app.fulfillmentOrderId(), app.status(), app.fulfillmentStatus(), app.deliveryMethod(),
|
||||
app.applicationId() == 201 ? "2026-10-20T12:00" : app.roundKey(), AT.plusDays(1), app.customerConfirmedAt())).toList();
|
||||
var changed = new Snapshot(reworked, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of());
|
||||
assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.validateFrozen(root, items, changed)));
|
||||
assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), jdbc.queryForList(
|
||||
"SELECT relabel_round_key FROM ll_dispatch_item_source ORDER BY dispatch_item_id", String.class));
|
||||
}
|
||||
|
||||
/** 同箱唛跨申请及客户仍按本逻辑明细精确归属,不把整贡献件数复制给本次部分扫描。 */
|
||||
@Test
|
||||
void sameCartonMarkAllocatesOnlySelectedApplicationAndCustomer() {
|
||||
capture();
|
||||
var known = allocate(first, 101, 1, 3, "SHARED", 5);
|
||||
assertEquals(1, known.size()); assertEquals(row(11).getId(), known.get(0).dispatchItemSourceId());
|
||||
assertEquals(1, known.get(0).packages()); assertEquals(3L, known.get(0).productQuantity());
|
||||
var unknown = allocate(second, 101, 1, 0, "SHARED", 5);
|
||||
assertEquals(row(12).getId(), unknown.get(0).dispatchItemSourceId());
|
||||
assertNull(unknown.get(0).productQuantity());
|
||||
var otherCustomer = allocate(third, 102, 1, 1, "SHARED", 4);
|
||||
assertEquals(row(13).getId(), otherCustomer.get(0).dispatchItemSourceId());
|
||||
assertEquals(1L, otherCustomer.get(0).productQuantity());
|
||||
}
|
||||
|
||||
/** 分配只减本来源真实已消费额度,耗尽后不能向同位置另一申请 FIFO 借箱。 */
|
||||
@Test
|
||||
void consumedAllowanceCannotBorrowFromAnotherApplicationOnSamePosition() {
|
||||
capture();
|
||||
var firstPart = allocate(first, 101, 1, 3, "SHARED", 5).get(0);
|
||||
pick(1, "101", 1001, firstPart);
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 2, 7, "SHARED", 4));
|
||||
var lastPart = allocate(first, 101, 1, 7, "SHARED", 4).get(0);
|
||||
assertEquals(firstPart.dispatchItemSourceId(), lastPart.dispatchItemSourceId());
|
||||
assertEquals(7L, lastPart.productQuantity());
|
||||
pick(2, "101", 1001, lastPart);
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, "SHARED", 3));
|
||||
var separate = allocate(second, 101, 3, 0, "SHARED", 3).get(0);
|
||||
assertEquals(row(12).getId(), separate.dispatchItemSourceId());
|
||||
assertEquals(3, separate.packages()); assertNull(separate.productQuantity());
|
||||
assertEquals(2, repository.picked("101", row(11).getId()));
|
||||
assertEquals(0, repository.picked("101", row(12).getId()));
|
||||
}
|
||||
|
||||
/** 排车、客户、逻辑明细和实际位置任一不符都不能消费已有真实贡献。 */
|
||||
@Test
|
||||
void allocateRejectsWrongDispatchCustomerItemAndPosition() {
|
||||
capture();
|
||||
assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH + 1, first,
|
||||
101, 1, 0, "SHARED", List.of(stock(101, 601, 5)))));
|
||||
var wrongCustomer = item(11, "102", "RL-201", 1001, PALLET, 2);
|
||||
assertThrows(BizException.class, () -> allocate(wrongCustomer, 102, 1, 0, "SHARED", 4));
|
||||
var wrongItem = item(999, "101", "RL-201", 1001, PALLET, 2);
|
||||
assertThrows(BizException.class, () -> allocate(wrongItem, 101, 1, 0, "SHARED", 5));
|
||||
assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first,
|
||||
102, 1, 0, "SHARED", List.of(stock(101, 601, 5)))));
|
||||
assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first,
|
||||
101, 1, 0, "SHARED", List.of())));
|
||||
}
|
||||
|
||||
/** 聚合库存可用数、其他申请额度及变更后的余额都不能补足本来源超量扫描。 */
|
||||
@Test
|
||||
void allocateRejectsExcessPackagesWrongBalanceAndChangedHold() {
|
||||
capture();
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 3, 10, null, 5));
|
||||
assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first,
|
||||
101, 1, 3, null, List.of(stock(101, 999, 5)))));
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 1, 3, null, 6));
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 2, 9, null, 5));
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 1, 11, null, 5));
|
||||
}
|
||||
|
||||
/** 同申请同位置有多明细时,部分箱数必须有明确箱唛,不能按记录顺序拆分。 */
|
||||
@Test
|
||||
void partialMultiContributionPickRequiresExactCartonMark() {
|
||||
addAnotherItemInFirstApplication();
|
||||
capture();
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, null, 6));
|
||||
var extra = allocate(first, 101, 1, 0, "EXTRA", 6);
|
||||
assertEquals(1, extra.size()); assertNull(extra.get(0).productQuantity());
|
||||
assertEquals(304L, repository.find(extra.get(0).dispatchItemSourceId()).getRelabelItemId());
|
||||
var original = allocate(first, 101, 1, 3, "SHARED", 6);
|
||||
assertEquals(301L, repository.find(original.get(0).dispatchItemSourceId()).getRelabelItemId());
|
||||
assertEquals(3L, original.get(0).productQuantity());
|
||||
}
|
||||
|
||||
/** 全量多明细扫描分别保留已知件数与未知空值,不复制整托或整个贡献的件数。 */
|
||||
@Test
|
||||
void fullMultiContributionPickKeepsKnownAndUnknownPieceFactsSeparate() {
|
||||
addAnotherItemInFirstApplication();
|
||||
capture();
|
||||
var allocated = allocate(first, 101, 3, 10, null, 6);
|
||||
assertEquals(2, allocated.size());
|
||||
assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum());
|
||||
assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum());
|
||||
assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count());
|
||||
assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6));
|
||||
}
|
||||
|
||||
/** SQL 只能删除未被真实拣货引用的来源,不能连带删除同实托已消费申请及其他客户。 */
|
||||
@Test
|
||||
void removeUnpickedCannotDeleteAnySourceWithActualPick() {
|
||||
capture();
|
||||
var kept = row(11).getId();
|
||||
pick(1, "101", 1001, allocate(first, 101, 1, 3, "SHARED", 5).get(0));
|
||||
transaction.executeWithoutResult(status -> {
|
||||
service.removeUnpicked(DISPATCH, 11);
|
||||
service.removeUnpicked(DISPATCH, 12);
|
||||
service.removeUnpicked(DISPATCH + 1, 13);
|
||||
});
|
||||
assertNotNull(repository.find(kept));
|
||||
assertEquals(List.of(11L, 13L), repository.all(DISPATCH).stream().map(DispatchItemSource::getDispatchItemId).toList());
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick_order WHERE dispatch_item_source_id=?", Integer.class, kept));
|
||||
assertEquals(2, countSources());
|
||||
}
|
||||
|
||||
/** 创建本次完整受控来源输入,不暗示真实 provider 已参与本组件测试。 */
|
||||
private Snapshot snapshot(List<BoundSource> sources, List<Position> positions) {
|
||||
return new Snapshot(supplied.applications(), List.copyOf(sources), List.copyOf(positions), supplied.compositionVersion(), true, List.of());
|
||||
}
|
||||
|
||||
/** 在第一申请同位置增加一条未知件数明细,用于验证多贡献精确分配。 */
|
||||
private void addAnotherItemInFirstApplication() {
|
||||
first.setPackageCount(3);
|
||||
var sources = new ArrayList<>(supplied.sources());
|
||||
sources.add(source(201, "RL-201", 304, "101", PALLET, 101, 601, 504, 704, 1, null, "EXTRA"));
|
||||
supplied = snapshot(sources, List.of(position(101, "101", 601, PALLET, 6), position(102, "102", 602, PALLET, 4)));
|
||||
}
|
||||
|
||||
/** 真实服务事务只冻结受控输入,数据写入仍经过实际仓储和 Mapper。 */
|
||||
private void capture() {
|
||||
transaction.executeWithoutResult(status -> service.capture(root, items, items, AT));
|
||||
}
|
||||
|
||||
/** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */
|
||||
private List<DispatchItemSourceService.Allocation> allocate(DispatchItem item, long position, long packages,
|
||||
long quantity, String mark, long hold) {
|
||||
long balance = position == 102 ? 602 : 601;
|
||||
return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark,
|
||||
List.of(stock(position, balance, hold))));
|
||||
}
|
||||
|
||||
/** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */
|
||||
private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) {
|
||||
jdbc.update("""
|
||||
INSERT INTO ll_dispatch_pda_pick_order
|
||||
(id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""", id, customer, 9000 + id, allocation.dispatchItemSourceId(), fulfillment, allocation.packages(),
|
||||
allocation.productQuantity() == null ? 0 : allocation.productQuantity(), AT.plusSeconds(id));
|
||||
}
|
||||
|
||||
/** 读取一条逻辑明细的原冻结来源。 */
|
||||
private DispatchItemSource row(long itemId) {
|
||||
return repository.all(DISPATCH).stream().filter(s -> s.getDispatchItemId() == itemId).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
/** 从真实数据库核对事务提交后的贡献数量。 */
|
||||
private int countSources() {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class);
|
||||
}
|
||||
|
||||
/** 构造本次逻辑运输来源,真实履约订单独立于原入仓链。 */
|
||||
private DispatchItem item(long id, String customer, String no, long order, long pallet, int packages) {
|
||||
var item = new DispatchItem();
|
||||
item.setId(id); item.setDispatchOrderId(DISPATCH); item.setCustomerId(customer); item.setOrderId(order);
|
||||
item.setOverseasWarehouseId("1001"); item.setWarehousePalletId(pallet); item.setPackageCount(packages);
|
||||
item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(no); item.setSourceStage("RELABEL");
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 构造已确认的普通卡车换标申请事实。 */
|
||||
private Application application(long id, String no, String customer, long fulfillment) {
|
||||
return new Application(id, no, customer, fulfillment, "READY_FOR_OUTBOUND", "WAIT_PRE_DISPATCH", "TRUCK", "INITIAL", AT, AT);
|
||||
}
|
||||
|
||||
/** 构造独立冻结货源与箱级作业事实,通知和成功均不能替代实际绑定贡献。 */
|
||||
private BoundSource source(long app, String no, long item, String customer, long pallet, long position,
|
||||
long balance, long sourceBalance, long inbound, long packages, Long pieces, String mark) {
|
||||
return new BoundSource(app, no, item, customer, "1001", pallet, position, balance, sourceBalance, inbound,
|
||||
"IN-" + inbound, "CODE-" + inbound, packages, packages, pieces, "b".repeat(64),
|
||||
List.of(new UnitFact(item, mark, "NEW-" + mark, "RELABELLED", 1),
|
||||
new UnitFact(item + 1000, "EXCEPTION-" + item, null, "EXCEPTION", 1)));
|
||||
}
|
||||
|
||||
/** 同一实托可以含不同客户位置,每个位置的 HOLD 独立守恒。 */
|
||||
private Position position(long id, String customer, long balance, long pallet, long hold) {
|
||||
return new Position(id, customer, balance, pallet, 0, hold, 0, 0, 0, 1);
|
||||
}
|
||||
|
||||
/** 保留大额 AVAILABLE 以证明它不能补足本来源的冻结额度。 */
|
||||
private Stock stock(long position, long balance, long hold) {
|
||||
return new Stock(position, balance, PALLET, "1001", 1000, hold, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.mapper.*;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService;
|
||||
import com.baiyue.lastleg.mapper.*;
|
||||
import com.baiyue.lastleg.relabel.mapper.*;
|
||||
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
|
||||
import com.baiyue.lastleg.relabel.service.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
|
||||
class DispatchMergedRelabelPersistenceTest {
|
||||
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
|
||||
@ParameterizedTest @ValueSource(booleans={false,true})
|
||||
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
|
||||
var f=new Merged(crossCustomer);var group=f.group();
|
||||
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(2);assertThat(group.pallets()).hasSize(1);
|
||||
var created=f.create("merged",group.sources().stream().map(s->s.selection()).toList());
|
||||
assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().itemCount()).isEqualTo(2);assertThat(created.counts().packageCount()).isEqualTo(5);
|
||||
assertThat(f.count("ll_dispatch_physical_pallet_lock")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock")).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock",Long.class)).isEqualTo(1);
|
||||
var approved=f.f.approve(created);var before=f.pda.task(f.picker,created.dispatchNo());
|
||||
assertThat(before.items()).extracting(i->i.positions().stream().mapToLong(p->p.availablePackages()).sum()).containsExactlyInAnyOrder(2L,3L);
|
||||
var first=f.item("RL-A");var second=f.item("RL-B");
|
||||
var request=new DispatchCommands.Pick(approved.recordVersion(),"pick-A",101L,2L,0L,null,null);
|
||||
var picked=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request));
|
||||
assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request)).replayed()).isTrue();
|
||||
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(1);
|
||||
var halfway=f.pda.task(f.picker,created.dispatchNo());
|
||||
assertThat(halfway.items().stream().filter(i->i.sourceBusinessNo().equals("RL-B")).findFirst().orElseThrow().positions().get(0).availablePackages()).isEqualTo(3);
|
||||
var last=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),second,new DispatchCommands.Pick(picked.recordVersion(),"pick-B",crossCustomer?102L:101L,3L,0L,null,null)));
|
||||
assertThat(f.pda.task(f.picker,created.dispatchNo()).pickingReconciled()).isTrue();
|
||||
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2);
|
||||
assertThat(f.f.db.queryForObject("SELECT COALESCE(SUM(hold_packages+available_packages+allocated_packages+picked_packages),0) FROM ll_inventory_position",Long.class)).isZero();
|
||||
assertThat(f.count("ll_dispatch_pda_pick_order WHERE dispatch_item_source_id IS NOT NULL AND receipt_id IS NULL AND return_receipt_id IS NULL AND return_carton_id IS NULL")).isEqualTo(2);
|
||||
var context=f.f.service.preview(f.f.actor,created.dispatchNo());assertThat(context.cargo()).hasSize(2);
|
||||
assertThat(context.cargo()).extracting(c->c.sourceOrigin().sourceInboundOrderNo()).containsExactlyInAnyOrder("ORIGIN-A","ORIGIN-B");
|
||||
assertThat(context.cargo()).extracting(c->c.orderId()).containsExactlyInAnyOrder(9001L,9002L);
|
||||
var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5);
|
||||
var pallet=preview.pallets().get(0);
|
||||
var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(),
|
||||
List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null)));
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 缺一个申请、同量换原货源、轮次或逐件事实变化,都不能用旧选择冻结新事实。 */
|
||||
@Test void incompleteOrChangedSelectionRollsBackEveryClaimAndRoot() throws Exception {
|
||||
var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList();
|
||||
assertThatThrownBy(()->f.create("partial",List.of(selections.get(0)))).hasMessageContaining("完整选择");
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
|
||||
f.f.db.update("UPDATE ll_relabel_item SET source_inbound_order_no='CHANGED' WHERE id=302");
|
||||
assertThatThrownBy(()->f.create("stale",selections)).hasMessageContaining("版本已变化");
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_item_source")).isZero();
|
||||
}
|
||||
|
||||
/** 建单后轮次或原货源同量变化仍失败,前面任务/库存命令/归属/版本均不能留下。 */
|
||||
@ParameterizedTest @ValueSource(strings={"origin","round","fact"})
|
||||
void changedFrozenOriginPreventsPickingBeforeAnyInventoryWrite(String changed) throws Exception {
|
||||
var f=new Merged(false);var created=f.create("origin-drift",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created);
|
||||
if("origin".equals(changed))f.f.db.update("UPDATE ll_relabel_item SET source_inbound_code='OTHER' WHERE id=302");
|
||||
else if("round".equals(changed))f.f.db.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE application_id=202 AND action='COMPLETE'",f.f.json.writeValueAsString(Map.of("roundKey","OTHER")));
|
||||
else f.f.db.update("UPDATE ll_relabel_pda_unit_fact SET new_label='OTHER' WHERE application_id=202");
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),new DispatchCommands.Pick(approved.recordVersion(),"bad-origin",101L,2L,0L,null,null))))
|
||||
.hasMessageContaining("原货源依据已变化");
|
||||
assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_dispatch_pda_pick")).isZero();
|
||||
assertThat(f.f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
|
||||
}
|
||||
|
||||
/** 第二个申请归属失败时真实四个库存动作与首层任务一起回滚。 */
|
||||
@Test void lateAttributionFailureRollsBackInventoryAndAllowsSameCommandRetry() throws Exception {
|
||||
var f=new Merged(false);var created=f.create("rollback",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created);
|
||||
var attribution=spy(f.attribution);doThrow(new IllegalStateException("归属故障")).when(attribution).attributeRelabel(anyString(),any(),anyLong(),anyList());
|
||||
ReflectionTestUtils.setField(f.picking,"attribution",attribution);
|
||||
var request=new DispatchCommands.Pick(approved.recordVersion(),"retry",101L,2L,0L,null,null);
|
||||
assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request))).hasMessageContaining("归属故障");
|
||||
assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_inventory_ledger")).isZero();assertThat(f.count("ll_dispatch_pda_task")).isZero();
|
||||
ReflectionTestUtils.setField(f.picking,"attribution",f.attribution);
|
||||
assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request)).recordVersion()).isEqualTo(approved.recordVersion()+1);
|
||||
}
|
||||
|
||||
/** 取消按整车释放两份逻辑成员和一个物理拥有者,原业务号恢复不误释放新单。 */
|
||||
@Test void zeroScanCancellationRestoresBothApplicationsAndReleasesOnePallet() throws Exception {
|
||||
var f=new Merged(false);var created=f.create("cancel",f.group().sources().stream().map(s->s.selection()).toList());
|
||||
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-merged","重排");
|
||||
f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request));
|
||||
assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isZero();assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isZero();
|
||||
assertThat(f.count("ll_relabel_application WHERE fulfillment_status='WAIT_PRE_DISPATCH'")).isEqualTo(2);
|
||||
var next=f.create("next",f.group().sources().stream().map(s->s.selection()).toList());
|
||||
assertThat(f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(f.f.repository.find(next.dispatchNo()).getId());
|
||||
}
|
||||
|
||||
/** 两条真实建单事务竞争同一完整组,只能提交一车、两逻辑来源及一个物理拥有者。 */
|
||||
@Test void concurrentCreatesCannotSplitOrDoubleClaimTheMergedPallet() throws Exception {
|
||||
var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList();
|
||||
var pool=java.util.concurrent.Executors.newFixedThreadPool(2);var start=new java.util.concurrent.CountDownLatch(1);
|
||||
try {
|
||||
var results=new ArrayList<java.util.concurrent.Future<Boolean>>();
|
||||
for(int n=0;n<2;n++){final int own=n;results.add(pool.submit(()->{start.await();try{f.create("race-"+own,selections);return true;}catch(org.springframework.web.server.ResponseStatusException|com.baiyue.common.core.exception.BizException conflict){return false;}}));}
|
||||
start.countDown();int applied=0;for(var result:results)if(result.get(20,java.util.concurrent.TimeUnit.SECONDS))applied++;
|
||||
assertThat(applied).isEqualTo(1);assertThat(f.count("ll_dispatch_order")).isEqualTo(1);assertThat(f.count("ll_dispatch_item")).isEqualTo(2);
|
||||
assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isEqualTo(2);
|
||||
} finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();}
|
||||
}
|
||||
|
||||
/** H2方言适配仍保留四类来源恰好一个,缺凭据或伪造双凭据必须由数据库拒绝。 */
|
||||
@Test void actualAttributionConstraintRejectsMissingAndAmbiguousSources() throws Exception {
|
||||
var f=new Merged(false);
|
||||
assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",1,"customer_id","101","pick_id",1,"order_id",9001,"packages",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",2,"customer_id","101","pick_id",1,"order_id",9001,"packages",1,"receipt_id",1,"dispatch_item_source_id",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertThat(f.count("ll_dispatch_pda_pick_order")).isZero();
|
||||
}
|
||||
|
||||
/** 正式初始化的计划实托绑定参与真实候选SQL,同码另一仓位置不能混入。 */
|
||||
@Test void actualPlanSchemaUsesRealPhysicalBindingAndRejectsAnotherWarehouse() throws Exception {
|
||||
var f=new Merged(false);
|
||||
f.insert("ll_order","id",9100,"order_no","PLAN-ORDER","customer_id","101","source_region","OVERSEAS","source_inbound_id","PLAN-SOURCE","source_event_id","PLAN-SOURCE","source_loading_plan_id","PLAN-SOURCE","source_payload_hash","a".repeat(64));
|
||||
f.insert("ll_inbound_plan","id",9011,"customer_id","101","source_order_id",9100,"container_no","PLAN-CN","plan_no","PLAN-NO","overseas_warehouse_id",9,"creation_mode","WAREHOUSE_TRANSFER");
|
||||
f.insert("ll_inbound_plan_line","id",9010,"customer_id","101","plan_id",9011,"order_id",9100,"inbound_code","PLAN-IN","destination_type","FBA","fba_warehouse_code","ONT8","volume_cbm",BigDecimal.ONE);
|
||||
f.insert("ll_inbound_plan_pallet","id",9012,"customer_id","101","plan_id",9011,"pallet_no","PLAN-PALLET","warehouse_pallet_id",502,"destination_type","FBA","destination_warehouse_code","ONT8","sequence_no",1,"allocated_volume_cbm",BigDecimal.ONE);
|
||||
f.insert("ll_inbound_plan_pallet_allocation","id",9013,"customer_id","101","plan_id",9011,"line_id",9010,"pallet_id",9012,"allocated_volume_cbm",BigDecimal.ONE,"planned_package_count",4);
|
||||
f.insert("ll_inventory_balance","id",603,"customer_id","101","customer_name","测试","overseas_warehouse_id","9","inbound_order_id",7002,"inbound_code","PLAN-IN","sku_code","SKU","available_packages",4);
|
||||
f.insert("ll_inventory_position","id",103,"customer_id","101","balance_id",603,"overseas_warehouse_id","9","pallet_id",502,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-2","available_packages",4);
|
||||
// 相同码在另一仓另一个physical,不能成为父计划的来源。
|
||||
f.insert("ll_inventory_position","id",104,"customer_id","101","balance_id",603,"overseas_warehouse_id","99","pallet_id",999,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-OTHER","available_packages",8);
|
||||
var group=f.rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,9012L,null,null,null,null));
|
||||
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(1);assertThat(group.pallets()).hasSize(1);
|
||||
assertThat(group.pallets().get(0).warehousePalletId()).isEqualTo(502L);assertThat(group.sources().get(0).packages()).isEqualTo(4);
|
||||
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
|
||||
}
|
||||
|
||||
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
private static final class Merged {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda;
|
||||
final DispatchPickOrderAttributionService attribution=new DispatchPickOrderAttributionService();
|
||||
final DispatchContracts.Actor picker=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
Merged(boolean crossCustomer) throws Exception {
|
||||
var configuration=f.session.getConfiguration();
|
||||
for(var mapper:List.of(RelabelApplicationMapper.class,RelabelDispatchSourceMapper.class,PhysicalPalletSerialMapper.class,DispatchWholePdaReadMapper.class,
|
||||
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class,
|
||||
InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryOperationMapper.class,InventoryCommandMapper.class,ReturnStockReceiptMapper.class))configuration.addMapper(mapper);
|
||||
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
|
||||
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
|
||||
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
|
||||
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) {
|
||||
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
|
||||
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
|
||||
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
|
||||
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
|
||||
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
|
||||
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
|
||||
}
|
||||
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
|
||||
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
|
||||
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class))));
|
||||
ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class));
|
||||
ReflectionTestUtils.setField(rules,"relabelMeasurements",mock(RelabelPalletMeasurementService.class));ReflectionTestUtils.setField(rules,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(rules,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class));
|
||||
ReflectionTestUtils.setField(f.service,"sourceRules",rules);ReflectionTestUtils.setField(f.service,"relabelSources",snapshots);
|
||||
var inventory=new InventoryCommandService();
|
||||
ReflectionTestUtils.setField(inventory,"positions",f.session.getMapper(InventoryPositionMapper.class));ReflectionTestUtils.setField(inventory,"balances",f.session.getMapper(InventoryBalanceMapper.class));
|
||||
ReflectionTestUtils.setField(inventory,"ledgers",f.session.getMapper(InventoryLedgerMapper.class));ReflectionTestUtils.setField(inventory,"operations",f.session.getMapper(InventoryOperationMapper.class));ReflectionTestUtils.setField(inventory,"commands",f.session.getMapper(InventoryCommandMapper.class));ReflectionTestUtils.setField(inventory,"objectMapper",f.json);
|
||||
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);
|
||||
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);}
|
||||
application(201,"RL-A","101",9001,301,101,2,"A");application(202,"RL-B",crossCustomer?"102":"101",9002,302,crossCustomer?102:101,3,"B");
|
||||
}
|
||||
/** 真实业务来源入口获取组,不构造客户端版本。 */
|
||||
DispatchPlanningModels.SelectionGroup group(){return rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-A",501L,null));}
|
||||
/** 同一事务执行正式创建,真实选货来源替换中央夹具的受控普通计划。 */
|
||||
DispatchContracts.CommandResult create(String key,List<DispatchCommands.CargoSelection> selections){return f.tx.execute(s->f.service.create(f.actor,f.configured(key,selections)));}
|
||||
/** 按业务来源定位逻辑明细,不将表ID作为业务单号。 */
|
||||
long item(String no){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE source_business_no=?",Long.class,no);}
|
||||
/** 实际隔离库断言使用固定测试表和谓词。 */
|
||||
long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
|
||||
/** 测试输入使用实际模块必填字段,不创建假结构列。 */
|
||||
void insert(String table,Object... fields){var columns=new ArrayList<String>();var values=new ArrayList<Object>();for(int n=0;n<fields.length;n+=2){columns.add((String)fields[n]);values.add(fields[n+1]);}f.db.update("INSERT INTO "+table+"("+String.join(",",columns)+") VALUES("+String.join(",",Collections.nCopies(values.size(),"?"))+")",values.toArray());}
|
||||
/** 物理库存保持真实客户维度,两个申请可共享同一余额与位置。 */
|
||||
void balance(long id,String customer,long packages){insert("ll_inventory_balance","id",id,"customer_id",customer,"customer_name","测试客户","overseas_warehouse_id","9","overseas_warehouse_code","WH9","overseas_warehouse_name","测试仓","inbound_order_id",7001,"inbound_code","IN-7001","sku_code","MIXED","hold_packages",packages);}
|
||||
/** 真实仓位属于同一全局physical,商品件数未知不填商品数。 */
|
||||
void position(long id,long balance,String customer,long packages){insert("ll_inventory_position","id",id,"customer_id",customer,"balance_id",balance,"overseas_warehouse_id","9","pallet_id",501,"pallet_no","PL-501","warehouse_location_code","LOC-1","hold_packages",packages);}
|
||||
/** 两张申请保留两个原货源、真实生产者轮次和独立履约订单。 */
|
||||
void application(long id,String no,String customer,long order,long item,long position,int packages,String suffix) throws Exception {
|
||||
insert("ll_order","id",order,"order_no","FULFILL-"+suffix,"customer_id",customer,"source_region","OVERSEAS","source_inbound_id",no,"order_type","RELABEL_APPLICATION","package_count",packages,"source_event_id",no,"source_loading_plan_id",no,"source_payload_hash","a".repeat(64));
|
||||
insert("ll_relabel_application","id",id,"customer_id",customer,"relabel_no",no,"lastleg_order_id",order,"customer_order_no","CUSTOMER-"+suffix,"overseas_warehouse_id","9","label_deadline",LocalDateTime.of(2099,1,1,0,0),"contact_name","测试","contact_phone","10000000000","idempotency_key",no,"request_hash","a".repeat(64),"delivery_method","TRUCK","status","READY_FOR_OUTBOUND","fulfillment_status","WAIT_PRE_DISPATCH","customer_confirmed_at",LocalDateTime.of(2026,1,4,0,0));
|
||||
insert("ll_relabel_item","id",item,"application_id",id,"customer_id",customer,"product_name_zh","测试货","total_package_count",packages,"notified_package_count",packages,"carton_mark","OLD-"+suffix,"fba_carton_mark","NEW-"+suffix,"operation_type","CARTON_LABEL","source_inventory_balance_id",500+id,"source_inventory_position_id",position,"source_inbound_order_id",7000+id,"source_inbound_order_no","ORIGIN-"+suffix,"source_inbound_code","ORIGIN-CODE-"+suffix,"destination_fba_warehouse_code","ONT8","source_warehouse_code","WH9","source_warehouse_name","测试仓");
|
||||
insert("ll_relabel_pda_task","id",1000+id,"application_id",id,"customer_id",customer,"relabel_no",no,"overseas_warehouse_id",9,"status","WAIT_REVIEW","operator_key","worker","operator_name","测试","expected_packages",packages,"claimed_at",LocalDateTime.of(2026,1,1,0,0));
|
||||
history(2000+id,id,customer,"PALLET_BIND",Map.of("round","INITIAL","cartonMarks",List.of("OLD-"+suffix),"targetPallets",List.of("PL-501")),2);
|
||||
history(3000+id,id,customer,"COMPLETE",Map.of("roundKey","INITIAL"),3);
|
||||
insert("ll_relabel_pda_unit_fact","id",4000+id,"task_id",1000+id,"application_id",id,"item_id",item,"customer_id",customer,"old_label","OLD-"+suffix,"new_label","NEW-"+suffix,"result","RELABELLED","quantity",1,"operator_key","worker","operator_name","测试","occurred_at",LocalDateTime.of(2026,1,2,0,0));
|
||||
}
|
||||
/** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */
|
||||
void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));}
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPhysicalClaimMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchPhysicalClaimRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实模块DDL、MyBatis和事务证明物理拥有者不依赖任何第一来源客户。 */
|
||||
class DispatchPhysicalClaimPersistenceTest {
|
||||
private static final LocalDateTime AT=LocalDateTime.of(2026,10,19,12,0);
|
||||
|
||||
/** 同一主单双客户共享一条物理占用,另一操作人维护该主单也不变成跨车领取。 */
|
||||
@Test void twoCustomersOfOneDispatchShareExactlyOnePhysicalOwner() throws Exception {
|
||||
var f=new Fixture();var owner=f.dispatch(10);
|
||||
long physical=f.tx.execute(s->{long id=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,id,"101","RELABEL:A:501",10L,null,null,null);return id;});
|
||||
f.tx.executeWithoutResult(s->{assertThat(f.service.reserve(new DispatchPhysicalClaimService.Owner("9",10L,null,null,"another",null),List.of(501L),AT).get(501L)).isEqualTo(physical);f.member(2,physical,"202","RELABEL:B:501",10L,null,null,null);});
|
||||
assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne();
|
||||
assertThat(f.count("ll_dispatch_pallet_lock","physical_lock_id="+physical+" AND status='ACTIVE'")).isEqualTo(2);
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class).hasMessageContaining("其他有效运输");
|
||||
}
|
||||
|
||||
/** 第一逻辑来源移除后仍保持物理排他,最后成员结束才可交给下一主单。 */
|
||||
@Test void firstMemberReleaseCannotExposeRemainingCustomerPallet() throws Exception {
|
||||
var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);f.member(2,id,"202","B",10L,null,null,null);return id;});
|
||||
f.tx.executeWithoutResult(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero();});
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class);
|
||||
f.tx.executeWithoutResult(s->{f.endMember(2);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();});
|
||||
long next=f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT).get(501L));
|
||||
assertThat(next).isNotEqualTo(physical);
|
||||
f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero());
|
||||
assertThat(f.mapper.lockOriginal(next).getStatus()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
/** 最后一行释放后的后续失败必须恢复逻辑状态及原物理活动键。 */
|
||||
@Test void laterFailureRollsBackMemberAndPhysicalReleaseTogether() throws Exception {
|
||||
var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);return id;});
|
||||
assertThatThrownBy(()->f.tx.execute(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();throw new IllegalStateException("后续业务失败");})).hasMessageContaining("后续业务失败");
|
||||
assertThat(f.mapper.lockOriginal(physical).getActiveWarehousePalletId()).isEqualTo(501L);
|
||||
assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE' AND active_key='A'")).isOne();
|
||||
}
|
||||
|
||||
/** 草稿与容器共同拥有实托,同一token的另一容器不能把同托拆走。 */
|
||||
@Test void draftContainerIdentityIsPartOfExclusiveOwner() throws Exception {
|
||||
var f=new Fixture();var first=f.draft("DRAFT",1,AT.plusMinutes(30));
|
||||
long physical=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L));
|
||||
Long replay=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L));assertThat(replay).isEqualTo(physical);
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.draft("DRAFT",2,AT.plusMinutes(30)),List.of(501L),AT))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(new DispatchPhysicalClaimService.Owner("88",10L,null,null,"operator",null),List.of(501L),AT))).hasMessageContaining("起运仓");
|
||||
}
|
||||
|
||||
/** 过期原草稿全部成员失效后才能重领,迟到的旧释放不能清新主单活动键。 */
|
||||
@Test void expiryIsOwnedAndOldReleaseCannotAffectNewOwner() throws Exception {
|
||||
var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1));
|
||||
long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.minusSeconds(1));return id;});
|
||||
long next=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L));
|
||||
assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("EXPIRED");assertThat(f.count("ll_dispatch_pallet_lock","status='EXPIRED' AND active_key IS NULL")).isEqualTo(2);
|
||||
f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(draft,List.of(old),AT)).isZero());
|
||||
assertThat(f.mapper.lockOriginal(next).getActiveWarehousePalletId()).isEqualTo(501L);
|
||||
}
|
||||
|
||||
/** 到期时间不一致不能偷走仍有效成员,前面已失效的行也随本次失败恢复。 */
|
||||
@Test void nonexpiredMemberPreventsExpiredPhysicalHeadFromBeingStolen() throws Exception {
|
||||
var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1));
|
||||
long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.plusMinutes(1));return id;});
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT))).hasMessageContaining("尚未完整到期");
|
||||
assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("ACTIVE");assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 部分成员先迁移时物理拥有者不能变,整组迁移后一次更新成功。 */
|
||||
@Test void draftMoveRequiresAllPhysicalMembersInTheSameNextContainer() throws Exception {
|
||||
var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.draft("DRAFT",1,AT.plusMinutes(30)),List.of(501L),AT).get(501L);f.member(1,id,"101","A",null,"DRAFT",1L,AT.plusMinutes(30));f.member(2,id,"202","B",null,"DRAFT",1L,AT.plusMinutes(30));return id;});
|
||||
f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isZero();f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=2");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isOne();});
|
||||
assertThat(f.mapper.lockOriginal(physical).getDraftContainerId()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
/** 服务只在全部成员已迁移时更新原物理拥有者,失败会回滚已移的逻辑成员。 */
|
||||
@Test void moveServiceRollsBackPartialMembersAndThenMovesWholePallet() throws Exception {
|
||||
var f=new Fixture();var owner=f.draft("MOVE",1,AT.plusMinutes(30));
|
||||
long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"MOVE",1L,owner.expiresAt());f.member(2,head,"102","B",null,"MOVE",1L,owner.expiresAt());return head;});
|
||||
assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(id),2,AT);})).hasMessageContaining("全部逻辑来源");
|
||||
assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2);assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(1L);
|
||||
f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2");f.service.moveDraft(owner,List.of(id),2,AT);});
|
||||
assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
/** 物理续期核对所有原租约,错误仓/过期记录/不同租约均不能部分续期。 */
|
||||
@Test void renewServiceRequiresExactOriginalLeaseAndCannotReviveExpiredClaims() throws Exception {
|
||||
var f=new Fixture();var owner=f.draft("LEASE",1,AT.plusMinutes(10));
|
||||
long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"LEASE",1L,owner.expiresAt());return head;});
|
||||
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("99","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约");
|
||||
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(11),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约");
|
||||
f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30)));
|
||||
assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30));
|
||||
f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(30),AT.plusMinutes(30)));
|
||||
assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30));
|
||||
assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT.plusMinutes(31),AT.plusMinutes(30),AT.plusMinutes(50)))).hasMessageContaining("租约无效");
|
||||
assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30));
|
||||
}
|
||||
|
||||
/** 容器中只迁移所选整托,另一托不动;混入他人占用须连已移动成员一起回滚。 */
|
||||
@Test void movingOneOfTwoPalletsKeepsOtherPalletAndRejectsForeignClaim() throws Exception {
|
||||
var f=new Fixture();var owner=f.draft("SUBSET",1,AT.plusMinutes(30));
|
||||
var heads=f.tx.execute(s->{var all=f.service.reserve(owner,List.of(501L,502L),AT);f.member(1,all.get(501L),"101","A",null,"SUBSET",1L,owner.expiresAt());f.member(2,all.get(502L),"102","B",null,"SUBSET",1L,owner.expiresAt());return all;});
|
||||
long foreign=f.tx.execute(s->f.service.reserve(f.draft("OTHER",3,AT.plusMinutes(30)),List.of(503L),AT).get(503L));
|
||||
assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L),foreign),2,AT);})).hasMessageContaining("拥有者已变化");
|
||||
assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2);
|
||||
f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L)),2,AT);});
|
||||
assertThat(f.mapper.lockOriginal(heads.get(501L)).getDraftContainerId()).isEqualTo(2L);assertThat(f.mapper.lockOriginal(heads.get(502L)).getDraftContainerId()).isEqualTo(1L);
|
||||
assertThat(f.mapper.lockOriginal(foreign).getDraftContainerId()).isEqualTo(3L);
|
||||
}
|
||||
|
||||
/** 两个事务都读到未占用后竞争插入,活动实托唯一键仍只允许一个主单提交。 */
|
||||
@Test void concurrentDifferentDispatchesCannotAcquireTheSamePallet() throws Exception {
|
||||
var f=new Fixture();var real=f.repository;var intercepted=spy(real);var bothRead=new CyclicBarrier(2);
|
||||
doAnswer(call->{var found=real.lockActive(call.getArgument(0));if(found==null)bothRead.await(5,TimeUnit.SECONDS);return found;}).when(intercepted).lockActive(501L);
|
||||
var service=new DispatchPhysicalClaimService(intercepted);var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var tasks=new ArrayList<Future<String>>();
|
||||
for(int index=1;index<=2;index++) {final int own=index;tasks.add(pool.submit(()->{
|
||||
try {f.tx.executeWithoutResult(s->{long id=service.reserve(f.dispatch(own*10),List.of(501L),AT).get(501L);f.member(own,id,String.valueOf(own),"SOURCE-"+own,(long)own*10,null,null,null);});return "APPLIED";}
|
||||
catch(BizException conflict){return "CONFLICT";}
|
||||
}));}
|
||||
assertThat(List.of(tasks.get(0).get(10,TimeUnit.SECONDS),tasks.get(1).get(10,TimeUnit.SECONDS))).containsExactlyInAnyOrder("APPLIED","CONFLICT");
|
||||
} finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,TimeUnit.SECONDS)).isTrue();}
|
||||
assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne();assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isOne();
|
||||
}
|
||||
|
||||
/** 原主键释放查询使用物理顺序,即使创建时间与托盘编号顺序相反。 */
|
||||
@Test void releaseLocksOriginalReferencesByPhysicalPalletOrder() throws Exception {
|
||||
var f=new Fixture();long large=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(900L),AT).get(900L));
|
||||
long small=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(100L),AT).get(100L));
|
||||
var traced=spy(f.repository);var service=new DispatchPhysicalClaimService(traced);
|
||||
f.tx.executeWithoutResult(s->assertThat(service.releaseIfUnused(f.dispatch(10),List.of(large,small),AT)).isEqualTo(2));
|
||||
var locks=inOrder(traced);locks.verify(traced).lockOriginal(small);locks.verify(traced).lockOriginal(large);
|
||||
}
|
||||
|
||||
/** 空物理集合不伪造托盘,缺失或非法真实引用会在访问数据库前拒绝。 */
|
||||
@Test void nonphysicalSourcesDoNotCreatePlaceholderClaims() throws Exception {
|
||||
var f=new Fixture();Map<Long,Long> empty=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(),AT));assertThat(empty).isEmpty();
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(0L),AT))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(f.count("ll_dispatch_physical_pallet_lock","1=1")).isZero();
|
||||
}
|
||||
|
||||
/** 测试仅用本机隔离H2;表定义直接截取模块基线,不连接共享环境。 */
|
||||
static class Fixture {
|
||||
final JdbcTemplate db;final TransactionTemplate tx;final DispatchPhysicalClaimMapper mapper;
|
||||
final DispatchPhysicalClaimRepository repository;final DispatchPhysicalClaimService service;
|
||||
/** 实际Mapper与Spring事务共用数据源,唯一键及失败回滚均由数据库执行。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
|
||||
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchPhysicalClaimMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchPhysicalClaimMapper.class);repository=new DispatchPhysicalClaimRepository(mapper);service=new DispatchPhysicalClaimService(repository);
|
||||
String schema;try(var in=getClass().getResourceAsStream("/db/lastleg.sql")){schema=new String(Objects.requireNonNull(in).readAllBytes(),StandardCharsets.UTF_8);}
|
||||
for(var table:List.of("ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock")) {
|
||||
int start=schema.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");int end=schema.indexOf(") ENGINE=",start);
|
||||
if(start<0 || end<0)throw new IllegalStateException("模块物理占用表缺失");db.execute(schema.substring(start,end+1));
|
||||
}
|
||||
}
|
||||
/** 正式主单拥有者不绑定客户。 */
|
||||
DispatchPhysicalClaimService.Owner dispatch(long id){return new DispatchPhysicalClaimService.Owner("9",id,null,null,"operator",null);}
|
||||
/** 草稿拥有者使用真实token、容器、操作人及期限。 */
|
||||
DispatchPhysicalClaimService.Owner draft(String token,long container,LocalDateTime expires){return new DispatchPhysicalClaimService.Owner("9",null,token,container,"operator",expires);}
|
||||
/** 逻辑成员只是本切片准备输入,物理占用和释放始终走被测生产Mapper。 */
|
||||
void member(long id,long physical,String customer,String key,Long dispatch,String token,Long container,LocalDateTime expires){
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,warehouse_pallet_id,dispatch_order_id,draft_token,draft_container_id,operator_id,status,locked_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE',?,?)",id,physical,customer,key,key,mapper.selectById(physical).getWarehousePalletId(),dispatch,token,container,"operator",AT.minusMinutes(1),expires);
|
||||
}
|
||||
/** 模拟已校验的逻辑来源结束,为最后成员物理保护提供真实数据库输入。 */
|
||||
void endMember(long id){db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE id=?",id);}
|
||||
/** 核对隔离测试库实际状态。 */
|
||||
long count(String table,String predicate){return Objects.requireNonNull(db.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE "+predicate,Long.class));}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -55,7 +55,7 @@ class DispatchPickingPersistenceTest {
|
||||
}
|
||||
/** 归属失败时连模拟库存SQL也回滚,不能留下仅库存已扣而没有扫码/账本的部分结果。 */
|
||||
@Test void attributionFailureRollsBackInventoryPickTaskHeaderAndLedger() throws Exception {
|
||||
var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull(),isNull());
|
||||
var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"rollback-pick",11L,3,6,null,null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero();
|
||||
@@ -93,10 +93,11 @@ class DispatchPickingPersistenceTest {
|
||||
if(multipleCustomers)f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(12,'102',22,502,'PL-502','9',2,0,0,0,1)");
|
||||
doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any());
|
||||
doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
|
||||
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull(),isNull());
|
||||
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull());
|
||||
var sources=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));
|
||||
}
|
||||
/** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
|
||||
|
||||
+188
-172
@@ -10,15 +10,13 @@ import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard;
|
||||
import com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery;
|
||||
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
|
||||
@@ -26,9 +24,18 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService;
|
||||
import com.baiyue.lastleg.mapper.*;
|
||||
import com.baiyue.lastleg.relabel.mapper.*;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelBindingGuard;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
@@ -36,15 +43,12 @@ import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.ibatis.session.LocalCacheScope;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -70,153 +74,116 @@ class DispatchRepairWorkflowTest {
|
||||
private SqlSessionTemplate session;
|
||||
private final Map<Class<?>, Object> dependencies = new LinkedHashMap<>();
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
private DispatchService dispatch;
|
||||
private DispatchAggregateService dispatch;
|
||||
private DispatchService sourceRules;
|
||||
private DispatchAggregatePersistenceTest.Fixture aggregateFixture;
|
||||
private DeliveryTransferService delivery;
|
||||
private CustomerVisitService visits;
|
||||
private ExceptionOrderService exceptions;
|
||||
private DispatchFleetAdapter fleet;
|
||||
private DispatchCandidateMapper candidates;
|
||||
private OverseasFeeSourceService fees;
|
||||
private final Map<String,DispatchCommands.ReasonCommand> cancellationCommands=new LinkedHashMap<>();
|
||||
|
||||
/** 每个用例使用独立H2库,不连接运行环境;真实Mapper元数据生成最小测试表。 */
|
||||
/** 复用公开整车夹具的真实资源/占用/账本,扩展只发生在本测试的独立数据库。 */
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
db = new JdbcTemplate(source);
|
||||
transactions = new TransactionTemplate(new DataSourceTransactionManager(source));
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT);
|
||||
List<Class<?>> mappers = List.of(DispatchOrderMapper.class, DispatchItemMapper.class,
|
||||
DispatchPalletLockMapper.class, DispatchBolMapper.class, DispatchPdaTaskMapper.class,
|
||||
DispatchPdaPickMapper.class, DispatchPdaPickOrderMapper.class, OutboundOrderMapper.class,
|
||||
OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class,
|
||||
DeliveryOrderMapper.class, DeliveryOperationMapper.class, CustomerVisitMapper.class,
|
||||
LastlegOrderMapper.class, LastlegInboundOrderMapper.class, LastlegOrderStatusHistoryMapper.class,
|
||||
FleetTripDispatchMapper.class, FleetTripMapper.class, FleetBookingMapper.class, InventoryPositionMapper.class,
|
||||
InventoryBalanceMapper.class, DispatchFeeCargoMapper.class, ExceptionReturnItemMapper.class, ExceptionOrderMapper.class,
|
||||
RelabelApplicationMapper.class, RelabelItemMapper.class, PdaRelabelHistoryMapper.class,
|
||||
RelabelPalletMeasurementMapper.class, ReturnInventoryCartonMapper.class, DispatchCandidateMapper.class,
|
||||
WarehouseTransferOrderMapper.class);
|
||||
mappers.forEach(configuration::addMapper);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(source);
|
||||
factory.setConfiguration(configuration);
|
||||
factory.afterPropertiesSet();
|
||||
session = new SqlSessionTemplate(factory.getObject());
|
||||
for (Class<?> mapper : mappers) dependencies.put(mapper, session.getMapper(mapper));
|
||||
// 不复制正式SQL,也不声称验证生产DDL;只构造本文件使用的实体表。
|
||||
for (Class<?> mapper : mappers) for (var parent : mapper.getGenericInterfaces()) {
|
||||
if (!(parent instanceof java.lang.reflect.ParameterizedType generic)
|
||||
|| generic.getRawType() != com.baomidou.mybatisplus.core.mapper.BaseMapper.class) continue;
|
||||
var table = TableInfoHelper.getTableInfo((Class<?>) generic.getActualTypeArguments()[0]);
|
||||
var columns = new ArrayList<String>();
|
||||
if (table.havePK()) columns.add(table.getKeyColumn() + " " + sqlType(table.getKeyType())
|
||||
+ (table.getIdType() == com.baomidou.mybatisplus.annotation.IdType.AUTO ? " AUTO_INCREMENT" : "") + " PRIMARY KEY");
|
||||
table.getFieldList().forEach(field -> columns.add(field.getColumn() + " " + sqlType(field.getPropertyType())));
|
||||
db.execute("CREATE TABLE IF NOT EXISTS " + table.getTableName() + " (" + String.join(",", columns) + ")");
|
||||
aggregateFixture=new DispatchAggregatePersistenceTest.Fixture();db=aggregateFixture.db;transactions=aggregateFixture.tx;session=aggregateFixture.session;
|
||||
List<Class<?>> mappers=List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchBolMapper.class,DispatchPdaTaskMapper.class,
|
||||
DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class,
|
||||
DeliveryOrderMapper.class,DeliveryOperationMapper.class,CustomerVisitMapper.class,LastlegOrderMapper.class,LastlegInboundOrderMapper.class,LastlegOrderStatusHistoryMapper.class,
|
||||
FleetTripDispatchMapper.class,FleetTripMapper.class,FleetBookingMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,DispatchFeeCargoMapper.class,
|
||||
ExceptionReturnItemMapper.class,ExceptionOrderMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,PdaRelabelHistoryMapper.class,RelabelPalletMeasurementMapper.class,
|
||||
ReturnInventoryCartonMapper.class,DispatchCandidateMapper.class,WarehouseTransferOrderMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,
|
||||
RelabelDispatchSourceMapper.class,PdaRelabelTaskMapper.class,PdaRelabelUnitFactMapper.class,PhysicalPalletSerialMapper.class);
|
||||
for(var mapper:mappers) {
|
||||
if(!session.getConfiguration().hasMapper(mapper))session.getConfiguration().addMapper(mapper);
|
||||
dependencies.put(mapper,session.getMapper(mapper));
|
||||
for(var parent:mapper.getGenericInterfaces()) {
|
||||
if(!(parent instanceof java.lang.reflect.ParameterizedType generic)||generic.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue;
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)generic.getActualTypeArguments()[0]);
|
||||
db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+table.getKeyColumn()+" "+sqlType(table.getKeyType())+" PRIMARY KEY)");
|
||||
for(var field:table.getFieldList())if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !List.of("customer_id","review_json").contains(field.getColumn()))db.execute("ALTER TABLE "+table.getTableName()+" ADD COLUMN IF NOT EXISTS "+field.getColumn()+" "+sqlType(field.getPropertyType()));
|
||||
}
|
||||
}
|
||||
db.execute("CREATE UNIQUE INDEX test_dispatch_active_lock ON ll_dispatch_pallet_lock(customer_id,active_key)");
|
||||
// 普通换标与退货换标候选查询含MySQL专用聚合;固定输入不替代完整集合校验或状态写入。
|
||||
candidates = spy(session.getMapper(DispatchCandidateMapper.class));
|
||||
doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(), any(), any());
|
||||
dependencies.put(DispatchCandidateMapper.class, candidates);
|
||||
dependencies.put(ObjectMapper.class, json);
|
||||
dependencies.put(LastlegOrderNoGenerator.class, new LastlegOrderNoGenerator());
|
||||
dependencies.put(FleetDispatchBindingGuard.class,
|
||||
new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class), json));
|
||||
dependencies.put(FleetDispatchWorkflowQuery.class,
|
||||
new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class), mapper(FleetTripMapper.class), json));
|
||||
var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class));
|
||||
dependencies.put(DeliveryExecutionRepository.class, execution);
|
||||
dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution));
|
||||
dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,
|
||||
new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),
|
||||
mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),
|
||||
mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),
|
||||
mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
// 本旧流程夹具只隔离新整车端口;父身份及已拣数量仍从本事务实际锁定的记录读取。
|
||||
var wholeExecution=mock(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class);
|
||||
when(wholeExecution.lockForArrangement(any(),anyString())).thenAnswer(call -> {
|
||||
var parent=mapper(DispatchOrderMapper.class).lockGlobalByDispatchNo(call.getArgument(1));
|
||||
var context=mock(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context.class);
|
||||
when(context.dispatchId()).thenReturn(parent.getId());
|
||||
when(context.status()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE);
|
||||
when(context.stage()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING);
|
||||
when(context.counts()).thenReturn(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Counts(0,0,0,
|
||||
parent.getPickedPackageCount()==null?0:parent.getPickedPackageCount(),0));
|
||||
return context;
|
||||
});
|
||||
dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,wholeExecution);
|
||||
dependencies.put(DispatchCargoSourceQuery.class,
|
||||
new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class))));
|
||||
dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class,
|
||||
mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class,
|
||||
mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
|
||||
fees = mock(OverseasFeeSourceService.class);
|
||||
dependencies.put(OverseasFeeSourceService.class, fees);
|
||||
var binding = wire(new RelabelBindingGuard());
|
||||
var measurements = wire(new RelabelPalletMeasurementService());
|
||||
var returnStock = wire(new ReturnRelabelDispatchStock());
|
||||
dependencies.put(RelabelBindingGuard.class, binding);
|
||||
dependencies.put(RelabelPalletMeasurementService.class, measurements);
|
||||
dependencies.put(ReturnRelabelDispatchStock.class, returnStock);
|
||||
dispatch = wire(new DispatchService());
|
||||
delivery = wire(new DeliveryTransferService());
|
||||
visits = wire(new CustomerVisitService());
|
||||
exceptions = wire(new ExceptionOrderService());
|
||||
fleet = new DispatchFleetAdapter(mapper(DispatchOrderMapper.class), mapper(DispatchPdaTaskMapper.class),
|
||||
mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class), mapper(DispatchItemMapper.class),
|
||||
mapper(DispatchPdaPickMapper.class), new DispatchPdaWorkflowService(), mapper(DeliveryOperationMapper.class));
|
||||
wire(fleet);
|
||||
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(128) NOT NULL,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))");
|
||||
for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column);
|
||||
candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates);
|
||||
dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator());
|
||||
var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class));
|
||||
dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json));
|
||||
var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json);
|
||||
dependencies.put(RelabelDispatchSourcePort.class,relabelSources);
|
||||
dependencies.put(PhysicalPalletSerialPort.class,new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(mapper(PhysicalPalletSerialMapper.class))));
|
||||
dependencies.put(RelabelBindingGuard.class,wire(new RelabelBindingGuard()));dependencies.put(RelabelPalletMeasurementService.class,wire(new RelabelPalletMeasurementService()));
|
||||
dependencies.put(ReturnRelabelDispatchStock.class,wire(new ReturnRelabelDispatchStock()));sourceRules=wire(new DispatchService());
|
||||
dispatch=aggregateFixture.service;ReflectionTestUtils.setField(dispatch,"sourceRules",sourceRules);
|
||||
ReflectionTestUtils.setField(dispatch,"relabelSources",new DispatchItemSourceService(
|
||||
new com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class)),relabelSources,json));
|
||||
dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class,aggregateFixture.repository);
|
||||
dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,dispatch);
|
||||
var cargo=new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class));
|
||||
fleet=new DispatchFleetAdapter(aggregateFixture.repository,mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class),mapper(DeliveryOperationMapper.class),cargo,dispatch,json);
|
||||
dependencies.put(FleetDispatchBindingGuard.class,new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class),fleet));
|
||||
dependencies.put(FleetDispatchWorkflowQuery.class,new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class),fleet));
|
||||
var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution));
|
||||
dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees);
|
||||
delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService());
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery"));
|
||||
}
|
||||
|
||||
/** 真实权限上下文不泄漏到其他测试。 */
|
||||
@org.junit.jupiter.api.AfterEach void clearSecurity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();}
|
||||
/** 历史取消出库仍可追溯;取消旧排车后同货重排,重复取消不得释放新锁。 */
|
||||
@Test
|
||||
void cancelledOutboundThenDispatchCancellationAllowsNewArrangementWithoutReleasingItsLock() {
|
||||
seedPendingOutbound();
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,'C',1,900,501,'PLAN:900','PLAN:900','ACTIVE')");
|
||||
var source=seedPlanSource();
|
||||
db.update("UPDATE ll_dispatch_item SET source_key='PLAN:900',plan_pallet_id=900,order_id=9900,source_stage='RECEIVED',source_business_type='INBOUND_PLAN' WHERE id=4");
|
||||
db.update("INSERT INTO ll_dispatch_physical_pallet_lock(id,warehouse_id,warehouse_pallet_id,active_warehouse_pallet_id,dispatch_order_id,operator_id,status) VALUES(41,'9',501,501,1,'OP','ACTIVE')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,41,'101',1,900,501,'PLAN:900','PLAN:900','ACTIVE')");
|
||||
run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重新安排")));
|
||||
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("CANCELLED");
|
||||
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("CANCELLED");
|
||||
long operations = count("ll_delivery_operation");
|
||||
run(() -> dispatch.cancel("DP-1"));
|
||||
run(() -> cancel("DP-1"));
|
||||
assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("CANCELLED");
|
||||
assertThat(text("ll_dispatch_pallet_lock", "status", 40)).isEqualTo("RELEASED");
|
||||
assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=40", String.class)).isNull();
|
||||
doReturn(List.of(candidate(501).planPalletId(900L))).when(candidates).lockSelected("C", List.of(900L));
|
||||
var created = inTransaction(() -> dispatch.preArrange("C", "OP",
|
||||
new DispatchRequests.PreArrange(List.of(900L), null, null, null)));
|
||||
assertThat(text("ll_dispatch_physical_pallet_lock","status",41)).isEqualTo("RELEASED");
|
||||
var created = inTransaction(() -> dispatch.create(aggregateFixture.actor,create(List.of(source))));
|
||||
assertThat(created.dispatchNo()).isNotEqualTo("DP-1");
|
||||
assertThat(created.actions().canCancel()).isTrue();
|
||||
assertThat(created.actions().canTransfer()).isTrue();
|
||||
var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo());
|
||||
assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue();
|
||||
assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse();
|
||||
assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId());
|
||||
run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重复取消")));
|
||||
run(() -> dispatch.cancel("DP-1"));
|
||||
run(() -> cancel("DP-1"));
|
||||
assertThat(count("ll_outbound_order")).isEqualTo(1);
|
||||
assertThat(count("ll_delivery_operation")).isEqualTo(operations);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
|
||||
verify(fees, times(1)).listForBusiness("C", "DELIVERY_ORDER", 3L);
|
||||
verify(fees, times(1)).listForBusiness("101", "DELIVERY_ORDER", 3L);
|
||||
verify(fees, never()).voidSource(anyLong(), any(), any());
|
||||
}
|
||||
|
||||
/** 真实车次关联和已拣事实分别阻止取消,失败事务不修改单据及占用。 */
|
||||
/** 唯一自动车次关系保持;真实已拣阻止取消,失败事务不修改单据及关系。 */
|
||||
@Test
|
||||
void bindingThenPickingIndependentlyPreventOutboundCancellation() {
|
||||
void automaticTripRelationshipRemainsWhilePickingPreventsOutboundCancellation() {
|
||||
seedPendingOutbound();
|
||||
db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(51,'TR-1',1,'DP-1','C')");
|
||||
assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("解除关联");
|
||||
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
|
||||
db.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=1");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Long.class)).isEqualTo(1);
|
||||
var binding=db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1");
|
||||
db.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=1");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,dispatch_item_id,packages) VALUES(103,'101',4,1)");
|
||||
assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消"))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
|
||||
assertThat(count("ll_delivery_operation")).isZero();
|
||||
assertThat(db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1")).isEqualTo(binding);
|
||||
}
|
||||
|
||||
/** 未出库开始派送/拒签均拒绝;真实出库后允许开始,但普通状态编辑始终不能取消。 */
|
||||
@@ -224,23 +191,23 @@ class DispatchRepairWorkflowTest {
|
||||
@ValueSource(strings = {"DELIVERING", "REJECTED"})
|
||||
void deliveryActionsFollowActualOutboundAndNeverOfferAnIndependentCancellation(String target) {
|
||||
seedPendingOutbound();
|
||||
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
|
||||
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus(target, 0L, "提前操作"))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
|
||||
assertThat(count("ll_delivery_operation")).isZero();
|
||||
db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=2 WHERE id=1");
|
||||
db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=2,departed_at=CURRENT_TIMESTAMP WHERE id=1");
|
||||
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2");
|
||||
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
|
||||
assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, "已发车取消"))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY");
|
||||
run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
|
||||
run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus(target, 0L, "合法操作")));
|
||||
assertThat(text("ll_delivery_order", "status", 3)).isEqualTo(target);
|
||||
assertThat(db.queryForObject("SELECT version FROM ll_delivery_order WHERE id=3", Long.class)).isEqualTo(1);
|
||||
long operations = count("ll_delivery_operation");
|
||||
var replay = inTransaction(() -> visits.updateDeliveryStatus("C", "OP", "DL-1",
|
||||
var replay = inTransaction(() -> visits.updateDeliveryStatus("101", "OP", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus(target, 0L, "同状态重放")));
|
||||
assertThat(replay.replayed()).isTrue();
|
||||
assertThat(count("ll_delivery_operation")).isEqualTo(operations);
|
||||
@@ -250,52 +217,68 @@ class DispatchRepairWorkflowTest {
|
||||
@Test
|
||||
void departureManifestRejectsCancelledMissingAndIncompleteDeliveryCoverage() {
|
||||
seedPendingOutbound();
|
||||
db.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=2 WHERE id=1");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'C',102,4,104,2)");
|
||||
db.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING',picked_package_count=2 WHERE id=1");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'101',102,4,104,2)");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no) VALUES(100,'101','LM-100')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(104,'101',501,'9',2)");
|
||||
db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(106,'101',100,501,104,2,'IN-100')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(105,'101',103,106,100,2)");
|
||||
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNull();
|
||||
db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3");
|
||||
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
|
||||
var guard = (DeliveryExecutionGuard) dependencies.get(DeliveryExecutionGuard.class);
|
||||
assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("C", "OUT-1"))))
|
||||
assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNull();
|
||||
db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3");
|
||||
assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank();
|
||||
assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("101", "OUT-1"))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
db.update("UPDATE ll_delivery_order SET status='WAIT_DELIVERY',package_count=1 WHERE id=3");
|
||||
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
|
||||
assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank();
|
||||
db.update("DELETE FROM ll_delivery_order WHERE id=3");
|
||||
assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank();
|
||||
assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("READY_TO_DEPART");
|
||||
assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank();
|
||||
assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("ACTIVE");
|
||||
assertThat(text("ll_dispatch_order", "stage", 1)).isEqualTo("AFTER_LOADING");
|
||||
assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND");
|
||||
}
|
||||
|
||||
/** 两托部分选取和重复ID都必须零副作用,完整预排取消后仍可整单重新预排。 */
|
||||
@Test
|
||||
void ordinaryRelabelRequiresAllPalletsAndCanBeRearrangedAfterCancellation() {
|
||||
db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,overseas_warehouse_id,status,fulfillment_status,delivery_method) VALUES(70,'C','RL-1','WH-1','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK')");
|
||||
var confirmedAt=LocalDateTime.of(2026,10,19,12,5);
|
||||
db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,lastleg_order_id,overseas_warehouse_id,status,fulfillment_status,delivery_method,total_package_count,notified_package_count,item_count,updated_at,customer_confirmed_at) VALUES(70,'101','RL-1',9501,'9','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK',2,2,2,?,?)",confirmedAt,confirmedAt);
|
||||
db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,relabel_no,overseas_warehouse_id,status,reviewed_at,version) VALUES(72,70,'101','RL-1',9,'COMPLETED',?,1)",confirmedAt);
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,detained,package_count,overseas_warehouse_id) VALUES(9501,'101','LM-RL','RL-1',0,2,'9')");
|
||||
for (long pallet : List.of(501L, 502L)) {
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,pallet_no,overseas_warehouse_id,hold_packages) VALUES(?,'C',?,?,'WH-1',1)", pallet, pallet, "P-" + pallet);
|
||||
db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,carton_mark,notified_package_count) VALUES(?,'C',70,'CARTON_LABEL',?,?,1)", pallet, pallet, "BOX-" + pallet);
|
||||
db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(?,'101',?,1)",7000+pallet,"IN-"+pallet);
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(?,'101',?,?)",pallet,7000+pallet,"IN-"+pallet);
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(?,'101',?,?,?,'9',0,1,0,0,0)", pallet,pallet,pallet, "P-" + pallet);
|
||||
db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,source_inventory_balance_id,source_inbound_order_id,source_inbound_order_no,source_inbound_code,carton_mark,notified_package_count,total_package_count) VALUES(?,'101',70,'CARTON_LABEL',?,?,?,?,?,?,1,1)",
|
||||
pallet,pallet,pallet,7000+pallet,"IN-"+pallet,"IN-"+pallet,"BOX-"+pallet);
|
||||
db.update("INSERT INTO ll_relabel_pda_unit_fact(id,task_id,application_id,item_id,customer_id,old_label,new_label,result,quantity,occurred_at) VALUES(?,72,70,?,'101',?,?,'RELABELLED',1,?)",
|
||||
8000+pallet,pallet,"BOX-"+pallet,"NEW-BOX-"+pallet,confirmedAt.minusMinutes(3));
|
||||
}
|
||||
db.update("INSERT INTO ll_relabel_pda_history(id,customer_id,application_id,action,detail_json) VALUES(71,'C',70,'PALLET_BIND',?)",
|
||||
"{\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}");
|
||||
doReturn(List.of(candidate(501).orderRefs("RL-1").packageCount(1), candidate(502).orderRefs("RL-1").packageCount(1)))
|
||||
.when(candidates).relabelAvailable("C", null, "RL-1");
|
||||
db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(71,72,'101',70,'PALLET_BIND',?,?)",
|
||||
"{\"round\":\"INITIAL\",\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}",confirmedAt.minusMinutes(4));
|
||||
db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(73,72,'101',70,'COMPLETE',?,?)",
|
||||
"{\"roundKey\":\"INITIAL\"}",confirmedAt.minusMinutes(2));
|
||||
doReturn(List.of(relabelCandidate(501),relabelCandidate(502)))
|
||||
.when(candidates).relabelAvailable("101", null, "RL-1");
|
||||
for (var selection : List.of(List.of(501L), List.of(501L, 501L, 502L))) {
|
||||
assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(selection))))
|
||||
assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(selection))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(count("ll_dispatch_order")).isZero();
|
||||
assertThat(count("ll_dispatch_item")).isZero();
|
||||
assertThat(count("ll_dispatch_pda_task")).isZero();
|
||||
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH");
|
||||
}
|
||||
var first = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))));
|
||||
assertThat(first.actions().canRemovePallet()).isFalse();
|
||||
var first = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L))));
|
||||
assertThat(dispatch.items(aggregateFixture.actor,first.dispatchNo()).items()).allSatisfy(i->assertThat(i.remove().allowed()).isFalse());
|
||||
assertThat(count("ll_dispatch_item")).isEqualTo(2);
|
||||
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("PRE_DISPATCH_CREATED");
|
||||
assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))))
|
||||
assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L)))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
run(() -> dispatch.cancel(first.dispatchNo()));
|
||||
run(() -> cancel(first.dispatchNo()));
|
||||
assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH");
|
||||
var second = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))));
|
||||
var second = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L))));
|
||||
assertThat(second.dispatchNo()).isNotEqualTo(first.dispatchNo());
|
||||
assertThat(count("ll_dispatch_order")).isEqualTo(2);
|
||||
}
|
||||
@@ -306,30 +289,31 @@ class DispatchRepairWorkflowTest {
|
||||
void mixedPalletUsesBothActualSourcesForBindingAndFreight(String displayInbound) throws Exception {
|
||||
seedPendingOutbound();
|
||||
db.update("UPDATE ll_dispatch_item SET inbound_code=?,package_count=5 WHERE id=4", displayInbound);
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'C',81,'IN-NORMAL'),(82,'C',83,'IN-EXT')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'C',80,501,'P-501','WH-1',3),(85,'C',82,501,'P-501','WH-1',2)");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'C','LM-NORMAL','IN-NORMAL'),(83,'C','LM-EXT','IN-EXT')");
|
||||
db.update("UPDATE ll_dispatch_order SET package_count=5 WHERE id=1");db.update("UPDATE ll_fleet_trip_dispatch SET package_count=5 WHERE dispatch_id=1");
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'101',81,'IN-NORMAL'),(82,'101',83,'IN-EXT')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'101',80,501,'P-501','9',3),(85,'101',82,501,'P-501','9',2)");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'101','LM-NORMAL','IN-NORMAL'),(83,'101','LM-EXT','IN-EXT')");
|
||||
// 该测试同时校验客户隔离,不能把同物理托盘编号的他客库存算进来。
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'OTHER',87,'FOREIGN')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'OTHER',86,501,'WH-1',99)");
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'202',87,'FOREIGN')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'202',86,501,'9',99)");
|
||||
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)");
|
||||
var realCargo = mapper(DispatchFeeCargoMapper.class).list("C", 1);
|
||||
var realCargo = mapper(DispatchFeeCargoMapper.class).list("101", 1);
|
||||
assertThat(realCargo).extracting(row -> row.inboundCode()).containsExactly("IN-NORMAL", "IN-EXT");
|
||||
assertThat(realCargo).extracting(row -> row.packages()).containsExactly(3L, 2L);
|
||||
var sources = inTransaction(() -> fleet.sourceCargo("DP-1"));
|
||||
var sources = inTransaction(() -> fleet.sourceCargo("DP-1","101"));
|
||||
assertThat(sources).extracting(FleetDispatchGateway.SourceCargo::inboundCode)
|
||||
.containsExactlyInAnyOrder("IN-NORMAL", "IN-EXT");
|
||||
assertThat(sources.stream().mapToInt(FleetDispatchGateway.SourceCargo::packages).sum()).isEqualTo(5);
|
||||
seedExternalBooking();
|
||||
var reservations = new FleetBookingReservations(mapper(FleetBookingMapper.class), json);
|
||||
var trip = new FleetTripEntity(); trip.setId(91L); trip.setTripNo("TR-1");
|
||||
var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId());
|
||||
run(() -> reservations.bindIncoming(trip,
|
||||
new FleetDispatchGateway.Snapshot(1L, "DP-1", "C", "WH-1", 5), sources, "OP"));
|
||||
new FleetDispatchGateway.Snapshot(1L, "DP-1", "9", 5), "101", sources, "OP"));
|
||||
assertThat(text("ll_fleet_external_booking", "status", 90)).isEqualTo("BOUND");
|
||||
db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(91,'TR-1',1,'DP-1','C')");
|
||||
assertThat(mapper(FleetTripDispatchMapper.class).findByDispatchNo("DP-1").getTripId()).isEqualTo(trip.getId());
|
||||
var freight = new FleetBookingFreight(mapper(FleetBookingMapper.class), mapper(FleetTripDispatchMapper.class), reservations, json);
|
||||
var preview = inTransaction(() -> freight.preview(1L, "DP-1", "C", sources));
|
||||
var preview = inTransaction(() -> freight.preview(1L, "DP-1", "101", sources));
|
||||
assertThat(preview.requiresDeliveryFee()).isTrue();
|
||||
assertThat(preview.quotes()).singleElement().satisfies(quote -> {
|
||||
assertThat(quote.bookingNo()).isEqualTo("FB-EXT");
|
||||
@@ -348,13 +332,13 @@ class DispatchRepairWorkflowTest {
|
||||
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2");
|
||||
db.update("UPDATE ll_dispatch_item SET source_stage='RETURN_RELABEL',source_business_type='RELABEL_ORDER',source_business_no='RL-1',inbound_code='RL-1' WHERE id=4");
|
||||
db.update("UPDATE ll_outbound_pallet_item SET inbound_code='RL-1' WHERE id=5");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'C','LM-RL','RL-1','DL-1','OUT-1',2,'WH-1')");
|
||||
db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'C','RL-1',2)");
|
||||
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'C','BOX-RL','P-501','WH-1','OUTBOUNDED','RL-1',6)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,6)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages,product_quantity) VALUES(105,'C',103,100,1,6)");
|
||||
var relations = inTransaction(() -> exceptions.returnCandidates("C", "DL-1"));
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'101','LM-RL','RL-1','DL-1','OUT-1',2,'9')");
|
||||
db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'101','RL-1',2)");
|
||||
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'101','BOX-RL','P-501','9','OUTBOUNDED','RL-1',6)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'101',102,4,101,1,6)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,return_carton_id,order_id,packages,product_quantity) VALUES(105,'101',103,101,100,1,6)");
|
||||
var relations = inTransaction(() -> exceptions.returnCandidates("101", "DL-1"));
|
||||
assertThat(relations).hasSize(1);
|
||||
assertThat(relations.get(0).returnSources()).singleElement().satisfies(source -> {
|
||||
var actual = json.valueToTree(source);
|
||||
@@ -367,11 +351,14 @@ class DispatchRepairWorkflowTest {
|
||||
|
||||
/** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */
|
||||
private void seedPendingOutbound() {
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)");
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')");
|
||||
var shell=inTransaction(()->aggregateFixture.fleet.createDispatchShell("OP",new FleetExecutionModels.DispatchShellRequest(1,"DP-1",9,"seed-physical-trip","b".repeat(64),null,null,null,null)));
|
||||
db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,dispatch_review_json,loading_review_json,resources_json,item_count,pallet_count,package_count,picked_package_count,shipped_package_count) VALUES(1,'DP-1',?,'9','ACTIVE','BEFORE_LOADING',0,0,?,'DRAFT',0,?,?,?,1,1,2,0,0)",shell.tripId(),"c".repeat(64),"{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"vehicle\":null,\"driver\":null}");
|
||||
run(()->aggregateFixture.fleet.linkDispatchShell("OP",1,"DP-1",shell.tripNo()));
|
||||
db.update("UPDATE ll_fleet_trip_dispatch SET package_count=2 WHERE dispatch_id=1");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count,pallet_count) VALUES(2,'101','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2,1)");
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count,pallet_count) VALUES(3,'101','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2,1)");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'101',1,501,'P-501','9',2,'FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'101',2,501,'P-501',2,0,'WAIT_PICK','ONT8')");
|
||||
}
|
||||
|
||||
/** 固定真实外接实收两箱及约定金额,后续绑定和报价核对均运行生产逻辑。 */
|
||||
@@ -379,26 +366,55 @@ class DispatchRepairWorkflowTest {
|
||||
var cargo = new FleetBookingModels.Cargo("FBA:ONT8", "外接货", 2, 1, BigDecimal.TEN,
|
||||
BigDecimal.ONE, "PALLET", new BigDecimal("60.00"), "CNY", null, null, null);
|
||||
var receipt = new FleetBookingModels.AcceptedReceipt(new FleetBookingIncomingGateway.Evidence(
|
||||
"WT-EXT", "IN-EXT", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1",
|
||||
"WT-EXT", "IN-EXT", "9", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1",
|
||||
List.of(new FleetBookingIncomingGateway.Pallet("501", "P-501", 2, BigDecimal.TEN, BigDecimal.ONE)), 2),
|
||||
List.of(new FleetBookingModels.PalletSpaces("501", 1, null, null)), "OP", Instant.parse("2026-10-01T00:00:00Z"));
|
||||
// 来货业务编号和库存入仓码是两个标识,必须经真实来货登记关联,不能混用。
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'C','WT-EXT','IN-EXT','READY_FOR_DISPATCH')");
|
||||
db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,91,'TR-1','FB-EXT','WT-EXT','C','FBA:ONT8','RECEIVED',60,1,?,?)",
|
||||
json.writeValueAsString(cargo), json.writeValueAsString(receipt));
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'101','WT-EXT','IN-EXT','READY_FOR_DISPATCH')");
|
||||
var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId());
|
||||
db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,?,?,'FB-EXT','WT-EXT','101','FBA:ONT8','RECEIVED',60,1,?,?)",
|
||||
trip.getId(),trip.getTripNo(),json.writeValueAsString(cargo), json.writeValueAsString(receipt));
|
||||
}
|
||||
|
||||
/** 构造来源输入;真实门禁仍由DispatchService及数据库锁决定。 */
|
||||
private DispatchPalletCandidate candidate(long pallet) {
|
||||
return new DispatchPalletCandidate().warehousePalletId(pallet).palletNo("P-" + pallet)
|
||||
.customerId("C").overseasWarehouseId("WH-1").sourceStage("RECEIVED")
|
||||
return new DispatchPalletCandidate().warehousePalletId(pallet).orderId(9000+pallet).palletNo("P-" + pallet)
|
||||
.customerId("101").overseasWarehouseId("9").sourceStage("RECEIVED")
|
||||
.destinationType("FBA").destinationWarehouseCode("ONT8").fbaWarehouseCode("ONT8")
|
||||
.packageCount(2).grossWeightKg(BigDecimal.TEN).volumeCbm(BigDecimal.ONE);
|
||||
}
|
||||
|
||||
/** 同一申请两托共享真实履约订单,原入仓链保留在各自明细的冻结字段。 */
|
||||
private DispatchPalletCandidate relabelCandidate(long pallet) {
|
||||
return candidate(pallet).orderId(9501L).orderNo("LM-RL").orderRefs("RL-1").inboundCode("RL-1").packageCount(1).sourceStage("RELABEL");
|
||||
}
|
||||
|
||||
/** 保留整单换标申请入口,不直接调用私有校验。 */
|
||||
private DispatchRequests.PreArrange relabel(List<Long> pallets) {
|
||||
return new DispatchRequests.PreArrange(pallets, null, null, null, null, "RL-1");
|
||||
private DispatchCommands.Create relabel(List<Long> pallets) {
|
||||
var cargo=pallets.stream().map(pallet->{var candidate=relabelCandidate(pallet);
|
||||
return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",pallet,sourceRules.wholeSourceVersion(candidate,"RELABEL:RL-1:"+pallet,false));}).toList();
|
||||
return create(cargo);
|
||||
}
|
||||
/** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */
|
||||
private DispatchCommands.Create create(List<DispatchCommands.CargoSelection> cargo) {
|
||||
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of());
|
||||
}
|
||||
/** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */
|
||||
private void cancel(String number) {
|
||||
var request=cancellationCommands.computeIfAbsent(number,key->new DispatchCommands.ReasonCommand(aggregateFixture.repository.find(number).getRecordVersion(),"cancel-"+number,"取消原运输安排"));
|
||||
dispatch.cancel(aggregateFixture.actor,number,request);
|
||||
}
|
||||
/** 候选聚合在边界固定,真实订单/计划根/库存和摘要仍由当前提供者锁定验证。 */
|
||||
private DispatchCommands.CargoSelection seedPlanSource() {
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,detained) VALUES(9900,'101','LM-PLAN',0)");
|
||||
db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id,source_order_id) VALUES(900,'101','9',9900)");
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(900,'101',900,'P-501',501)");
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(9500,'101','IN-PLAN')");
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(9500,'101',9500,501,'P-501','9',2,0,0,0,0)");
|
||||
db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(9500,'101',9900,501,9500,2,'IN-PLAN')");
|
||||
var row=candidate(501).planId(900L).planPalletId(900L).orderId(9900L).inboundCode("IN-PLAN");
|
||||
doReturn(List.of("101")).when(candidates).findCustomerScopesByPlanPalletIds(List.of(900L));doReturn(List.of(row)).when(candidates).readSelected("101",List.of(900L));
|
||||
return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,900L,null,null,501L,sourceRules.wholeSourceVersion(row,"PLAN:900",false));
|
||||
}
|
||||
|
||||
/** 按类型注入实际依赖;未用到的外部能力保持未装配,调用时显式失败。 */
|
||||
|
||||
+76
-609
@@ -1,623 +1,90 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.service.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class DispatchServiceTest {
|
||||
/** 历史取消出库不阻止取消原排车,重放不影响后续占用。 */
|
||||
@Test void cancelledOutboundAllowsOriginalDispatchCancellationAndPreservesAppointmentHistory() {
|
||||
var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100");
|
||||
dispatch.setDispatchNo("DP-OLD"); dispatch.setStatus("PENDING_DEPARTURE");
|
||||
var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); outbound.setStatus("CANCELLED");
|
||||
var item = item(11L, "ONT8");
|
||||
when(orders.lockGlobalByDispatchNo("DP-OLD")).thenReturn(dispatch);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
var allocations = mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
ReflectionTestUtils.setField(service, "appointmentAllocations", allocations);
|
||||
service.cancel("DP-OLD"); service.cancel("DP-OLD");
|
||||
assertEquals("CANCELLED", dispatch.getStatus());
|
||||
verify(allocations, never()).removeItem(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
|
||||
verify(orders).updateById(dispatch);
|
||||
final DispatchService service=new DispatchService();
|
||||
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
|
||||
final DispatchCandidateMapper candidates=mock(DispatchCandidateMapper.class);
|
||||
final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class);
|
||||
final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class);
|
||||
final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class);
|
||||
final com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
final com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
@BeforeEach void setup(){
|
||||
ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper()));
|
||||
ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts);
|
||||
ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements);
|
||||
ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class));
|
||||
// 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。
|
||||
when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of()));
|
||||
ReflectionTestUtils.setField(service,"relabelSources",facts);
|
||||
ReflectionTestUtils.setField(service,"palletSerial",palletSerial);
|
||||
}
|
||||
|
||||
/** 普通换标部分选择与重复选择均在创建排车前拒绝。 */
|
||||
@Test void ordinaryRelabelRequiresTheWholeLockedApplicationBeforeAnyMutation() {
|
||||
var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelApplications", relabels);
|
||||
ReflectionTestUtils.setField(service, "relabelBindingGuard", mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
|
||||
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
|
||||
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
application.setDeliveryMethod("TRUCK"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
when(relabels.lockNo("100", "RL-1")).thenReturn(application);
|
||||
when(candidates.relabelAvailable("100", null, "RL-1")).thenReturn(List.of(candidate(10L, 900L, "ONT8"), candidate(11L, 901L, "ONT8")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
|
||||
new DispatchRequests.PreArrange(List.of(900L), null, null, null, null, "RL-1")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
|
||||
new DispatchRequests.PreArrange(List.of(900L, 900L), null, null, null, null, "RL-1")));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
verify(pdaTasks, never()).insert(any(DispatchPdaTask.class));
|
||||
verify(candidates, never()).markRelabelPreDispatched(any(), any());
|
||||
/** 空车返回仓范围内的确定空快照,不对不存在的申请、来源或实托取得锁。 */
|
||||
@Test void emptyExecutionSourcesHaveWarehouseScopedSnapshotWithoutAnySourceLocks(){
|
||||
var empty=service.lockExecutionSources("9",List.of());
|
||||
assertThat(empty.complete()).isTrue();assertThat(empty.applications()).isEmpty();assertThat(empty.sources()).isEmpty();
|
||||
assertThat(empty.positions()).isEmpty();assertThat(empty.blockReasons()).isEmpty();
|
||||
assertThat(empty.compositionVersion()).matches("[0-9a-f]{64}");
|
||||
assertThat(service.lockExecutionSources("9",List.of()).compositionVersion()).isEqualTo(empty.compositionVersion());
|
||||
assertThat(service.lockExecutionSources("10",List.of()).compositionVersion()).isNotEqualTo(empty.compositionVersion());
|
||||
verifyNoInteractions(facts,sources,palletSerial,relabels,measurements);
|
||||
}
|
||||
|
||||
@Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() {
|
||||
var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1");
|
||||
when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
|
||||
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
|
||||
var references=List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:1","AP-1","C",
|
||||
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:10","FBA:ONT8",40))),
|
||||
new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:2","AP-2","C",
|
||||
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:11","FBA:ONT8",20))));
|
||||
when(allocations.references(List.of(1L))).thenReturn(java.util.Map.of(1L,references));
|
||||
var result=service.platformDetail("DP-1");assertNull(result.fbaAppointmentNo());assertEquals(references,result.appointmentBatches());
|
||||
verify(allocations).references(List.of(1L));
|
||||
/** 空车也不能缺失起运仓,不能把无范围请求解释成合法空来源。 */
|
||||
@Test void emptyExecutionSourcesStillRequireWarehouse(){
|
||||
for(String warehouse:Arrays.asList(null,""," "))assertThatThrownBy(()->service.lockExecutionSources(warehouse,List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("起运仓库");
|
||||
verifyNoInteractions(facts,sources,palletSerial);
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.NullAndEmptySource
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"})
|
||||
void directRelabelCannotResolveDispatchScopeOrBypassCandidateFiltering(String deliveryMethod) {
|
||||
var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelApplications", relabels);
|
||||
var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication();
|
||||
application.setCustomerId("100"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); application.setDeliveryMethod(deliveryMethod);
|
||||
when(relabels.findAnyByRelabelNo("RL-1")).thenReturn(application);
|
||||
when(relabels.lockNo("100", "RL-1")).thenReturn(application);
|
||||
assertThrows(ResponseStatusException.class, () -> service.resolveCustomerScopeForRelabel("RL-1"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP",
|
||||
new DispatchRequests.PreArrange(List.of(1L), null, null, null, null, "RL-1")));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
verify(candidates, never()).relabelAvailable(any(), any(), any());
|
||||
verify(relabelMeasurements, never()).requireMeasured(any());
|
||||
/** 非空货物缺实托或提供者拒绝时保留原错误,不能借空车分支绕过闭包。 */
|
||||
@Test void nonemptyExecutionSourcesRetainStrictProviderValidation(){
|
||||
var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();
|
||||
when(facts.snapshot(eq("9"),eq(List.of()))).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("实托集合不能为空");
|
||||
verify(facts).snapshot("9",List.of());
|
||||
item.setWarehousePalletId(501L);
|
||||
when(facts.snapshot("9",List.of(501L))).thenThrow(new IllegalStateException("完整实托来源已变化"));
|
||||
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化");
|
||||
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
|
||||
}
|
||||
|
||||
@Mock DispatchOrderMapper orders;
|
||||
@Mock DispatchItemMapper items;
|
||||
@Mock DispatchBolMapper bolLines;
|
||||
@Mock DispatchPalletLockMapper locks;
|
||||
@Mock DispatchCandidateMapper candidates;
|
||||
@Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates;
|
||||
@Mock DispatchDraftMapper drafts;
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
@Mock LastlegOrderMapper tailOrders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
|
||||
@Mock ReturnRelabelDispatchStock returnRelabelStock;
|
||||
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements;
|
||||
private DispatchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchService();
|
||||
ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
|
||||
ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements);
|
||||
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "bolLines", bolLines);
|
||||
ReflectionTestUtils.setField(service, "locks", locks);
|
||||
ReflectionTestUtils.setField(service, "candidates", candidates);
|
||||
ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates);
|
||||
ReflectionTestUtils.setField(service, "drafts", drafts);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
|
||||
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
|
||||
lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of());
|
||||
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
|
||||
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
|
||||
assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
LastlegOrder held = new LastlegOrder();
|
||||
held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true);
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(held));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
|
||||
null, null, null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
@Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){
|
||||
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null);
|
||||
when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101")));
|
||||
assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception {
|
||||
assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class,
|
||||
DispatchRequests.PreArrange.class).getAnnotation(Transactional.class));
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "ONT8")));
|
||||
|
||||
var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
|
||||
null, null, null));
|
||||
|
||||
assertEquals(DispatchService.PENDING_DEPARTURE, result.status());
|
||||
assertEquals("ONT8", result.route());
|
||||
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
|
||||
verify(orders).insert(dispatch.capture());
|
||||
ArgumentCaptor<DispatchPdaTask> pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class);
|
||||
verify(pdaTasks).insert(pdaTask.capture());
|
||||
assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId());
|
||||
assertEquals("WH-1", pdaTask.getValue().getWarehouseId());
|
||||
assertEquals("PDA_PENDING", pdaTask.getValue().getStatus());
|
||||
assertEquals(1, dispatch.getValue().getPalletCount());
|
||||
verify(locks).insert(any(DispatchPalletLock.class));
|
||||
verify(items).insert(any(DispatchItem.class));
|
||||
@Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){
|
||||
var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app);
|
||||
when(candidates.relabelAvailable("101",null,"RL-1")).thenReturn(List.of(relabelRow(501),relabelRow(502)));
|
||||
var selection=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"version");
|
||||
assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection))).hasMessageContaining("完整选择");
|
||||
assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection,selection))).hasMessageContaining("重复选择");
|
||||
verify(candidates,never()).markRelabelPreDispatched(any(),any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() {
|
||||
when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of(
|
||||
candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8")));
|
||||
when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(
|
||||
item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8")));
|
||||
|
||||
DispatchResponse result = service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null));
|
||||
|
||||
assertEquals("ONT8,ABE4", result.route());
|
||||
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
|
||||
verify(orders).insert(dispatch.capture());
|
||||
assertEquals("ONT8,ABE4", dispatch.getValue().getRoute());
|
||||
@Test void nonTruckRelabelCannotReachMeasurementsOrCargoMutation(){
|
||||
var app=new RelabelApplication();app.setCustomerId("101");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("EXPRESS");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app);
|
||||
// 当前生产者SQL已过滤非TRUCK;消费方读不到来源时在加锁和测量前拒绝。
|
||||
assertThatThrownBy(()->service.lockWholeSources("9",List.of(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"v")))).hasMessageContaining("来源已变化");
|
||||
verifyNoInteractions(measurements);verify(candidates).relabelAvailable("101",null,"RL-1");verify(candidates,never()).markRelabelPreDispatched(any(),any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private DispatchDraftContainer existingDraftGroup() {
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
draft.setId(20L); draft.setDraftToken("draft-1"); draft.setOperatorId("7");
|
||||
when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
|
||||
DispatchDraftContainer group = new DispatchDraftContainer();
|
||||
group.setId(21L); group.setDraftId(20L); group.setDraftToken("draft-1");
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void preArrangeAcceptsVolumeOnlyPlanPallet() {
|
||||
when(candidates.lockSelected("100", List.of(10L)))
|
||||
.thenReturn(List.of(candidate(10L, null, "ONT8", 0)));
|
||||
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
|
||||
null, null, null));
|
||||
|
||||
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
|
||||
verify(orders).insert(dispatch.capture());
|
||||
assertEquals(0, dispatch.getValue().getPackageCount());
|
||||
verify(items).insert(any(DispatchItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() {
|
||||
DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
|
||||
null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8",
|
||||
12, new BigDecimal("1.2000"), "RECEIVED");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
|
||||
assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsPalletWithoutFbaWarehouse() {
|
||||
DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
|
||||
"WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001",
|
||||
null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
|
||||
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
|
||||
when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
|
||||
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1"));
|
||||
|
||||
assertNotNull(lock.getDispatchOrderId());
|
||||
assertEquals("draft-1", lock.getDraftToken());
|
||||
assertEquals(21L, lock.getDraftContainerId());
|
||||
assertEquals("ACTIVE", draft.getStatus());
|
||||
assertNull(draft.getExpiresAt());
|
||||
verify(locks).updateById(lock);
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class));
|
||||
verify(drafts).updateById(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0);
|
||||
DispatchItem removed = new DispatchItem();
|
||||
removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L);
|
||||
DispatchItem remaining = new DispatchItem();
|
||||
remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7);
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
when(items.lockById("100", 1L, 11L)).thenReturn(removed);
|
||||
when(locks.selectList(any())).thenReturn(List.of(lock));
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining));
|
||||
|
||||
DispatchResponse result = service.removeItem("PC1", 11L);
|
||||
|
||||
assertEquals(1, result.palletCount());
|
||||
assertEquals(7, result.packageCount());
|
||||
assertEquals("RELEASED", lock.getStatus());
|
||||
verify(items).deleteById(11L);
|
||||
verify(orders).updateById(dispatch);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void rejectsPalletAlreadyLockedByAnotherDispatch() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
DispatchPalletLock locked = new DispatchPalletLock();
|
||||
when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidCandidateStageIsRejected() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.candidates("100", List.of(1L), null, null, "INVALID"));
|
||||
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCandidatesQueryAcrossCustomersWithoutPlanScope() {
|
||||
when(candidates.available(null, null, null, null, "RECEIVED", "draft-1")).thenReturn(List.of());
|
||||
|
||||
service.platformCandidates(null, null, "RECEIVED", "draft-1");
|
||||
|
||||
verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void relabelSourceDoesNotRequireOrQueryAnInboundPlan() {
|
||||
when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty());
|
||||
verify(candidates).relabelAvailable(null, "RL-1", null);
|
||||
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
|
||||
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
|
||||
when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100"));
|
||||
|
||||
assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L)));
|
||||
assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSelectedPlansThatSpanCustomers() {
|
||||
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200"));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.resolveCustomerScopeForPlans(List.of(1L, 2L)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() {
|
||||
assertEquals(List.of(), service.candidates(null, null, null, null, null, "draft-1"));
|
||||
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange(null, "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
DispatchItem ont8 = item(11L, "ONT8");
|
||||
DispatchItem abe4 = item(12L, "ABE4");
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8")));
|
||||
LocalDateTime departAt = LocalDateTime.of(2026, 9, 18, 10, 30);
|
||||
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null,
|
||||
departAt, null, null, null, LocalDateTime.now());
|
||||
|
||||
DispatchResponse result = service.update("PC1", request);
|
||||
|
||||
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
|
||||
assertEquals("ABC-1", result.vehiclePlate());
|
||||
assertEquals(departAt, result.departAt());
|
||||
assertEquals(departAt, dispatch.getDepartAt());
|
||||
assertEquals("ONT8,ABE4", result.route());
|
||||
assertEquals("ONT8,ABE4", dispatch.getRoute());
|
||||
verify(orders).updateById(dispatch);
|
||||
verify(fleetBindings).requireUnbound(1L);
|
||||
verify(items).findByDispatchOrder("100", 1L);
|
||||
}
|
||||
|
||||
@Test void boundDispatchCannotEditRemoveOrCancelIndependently() {
|
||||
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联"))
|
||||
.when(fleetBindings).requireUnbound(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("PC1", null));
|
||||
assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L));
|
||||
assertThrows(ResponseStatusException.class, () -> service.cancel("PC1"));
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRejectsAnExecutedDispatchWithoutWriting() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus("DEPARTED");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null,
|
||||
null, null, null, null, null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.update("PC1", request));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(orders, never()).updateById(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailUsesResolvedCustomerScopeAndLoadsItems() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L);
|
||||
dispatch.setCustomerId("200");
|
||||
dispatch.setDispatchNo("PC1");
|
||||
when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch);
|
||||
|
||||
service.detail("200", "PC1");
|
||||
|
||||
verify(items).findByDispatchOrder("200", 1L);
|
||||
verify(bolLines).findByDispatchOrder("200", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() {
|
||||
DispatchOrder first = new DispatchOrder();
|
||||
first.setId(1L); first.setCustomerId("100"); first.setDispatchNo("PC1");
|
||||
DispatchOrder second = new DispatchOrder();
|
||||
second.setId(2L); second.setCustomerId("200"); second.setDispatchNo("PC2");
|
||||
Page<DispatchOrder> resultPage = new Page<>(1, 20, 2);
|
||||
resultPage.setRecords(List.of(first, second));
|
||||
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage);
|
||||
|
||||
var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null));
|
||||
|
||||
assertEquals(2, result.total());
|
||||
verify(items).findByDispatchOrder("100", 1L);
|
||||
verify(items).findByDispatchOrder("200", 2L);
|
||||
verify(bolLines, never()).findByDispatchOrder(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformDetailUsesCustomerFromMatchedDispatch() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
|
||||
when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
|
||||
var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1",
|
||||
"NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138);
|
||||
when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected));
|
||||
|
||||
var result = service.platformDetail("PC1");
|
||||
|
||||
verify(items).findByDispatchOrder("200", 1L);
|
||||
verify(bolLines).findByDispatchOrder("200", 1L);
|
||||
assertEquals(List.of(expected), result.bolLines());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
when(locks.selectList(any())).thenReturn(List.of(lock));
|
||||
|
||||
service.cancel("PC1");
|
||||
|
||||
assertEquals(DispatchService.CANCELLED, dispatch.getStatus());
|
||||
assertEquals("RELEASED", lock.getStatus());
|
||||
assertEquals(null, lock.getActiveKey());
|
||||
verify(locks).updateById(lock);
|
||||
|
||||
service.cancel("PC1");
|
||||
verify(locks, org.mockito.Mockito.times(1)).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void receivedPalletUsesPhysicalPalletAsDispatchLockIdentity() {
|
||||
DispatchPalletCandidate mergedInboundOrder = candidate(10L, 900L, "ONT8");
|
||||
DispatchPalletCandidate plannedOnly = candidate(11L, null, "ONT8");
|
||||
|
||||
assertEquals("RECEIVED:900", mergedInboundOrder.sourcePalletKey());
|
||||
assertEquals("PLAN:11", plannedOnly.sourcePalletKey());
|
||||
}
|
||||
|
||||
@Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception {
|
||||
var candidate = candidate(10L, 900L, "ONT8", 2);
|
||||
candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701");
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate));
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L)));
|
||||
var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture());
|
||||
assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId());
|
||||
assertEquals(2, item.getValue().getPackageCount());
|
||||
var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture());
|
||||
assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId());
|
||||
verify(candidates, never()).lockSelected(any(), any());
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate);
|
||||
assertEquals("RETURN_RECEIPT", json.path("sourceType").asText());
|
||||
assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull());
|
||||
}
|
||||
|
||||
@Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() {
|
||||
var planned = candidate(10L, 900L, "ONT8", 3);
|
||||
var returned = candidate(11L, 901L, "ONT8", 2);
|
||||
returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned));
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned));
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)));
|
||||
var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture());
|
||||
assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount());
|
||||
var inserted = ArgumentCaptor.forClass(DispatchItem.class);
|
||||
verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture());
|
||||
var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow();
|
||||
var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow();
|
||||
assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount());
|
||||
assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount());
|
||||
assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId());
|
||||
var lock = ArgumentCaptor.forClass(DispatchPalletLock.class);
|
||||
verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture());
|
||||
assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream()
|
||||
.map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
@Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() {
|
||||
var planned = candidate(10L, 900L, "ONT8", 3);
|
||||
var returned = candidate(11L, 900L, "ONT8", 2);
|
||||
returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned));
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned));
|
||||
var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))));
|
||||
assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘"));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class));
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
|
||||
return candidate(planPalletId, warehousePalletId, destination, 12);
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination,
|
||||
int packageCount) {
|
||||
return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1",
|
||||
"LIP1-P001", destination, destination, destination, destination, packageCount,
|
||||
new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED");
|
||||
}
|
||||
|
||||
private DispatchItem item(long id, String fbaWarehouseCode) {
|
||||
DispatchItem item = new DispatchItem();
|
||||
item.setId(id);
|
||||
item.setFbaWarehouseCode(fbaWarehouseCode);
|
||||
return item;
|
||||
@Test void sourceIdentityCannotCombinePlanAndReturnReceipt(){
|
||||
assertThatThrownBy(()->DispatchService.wholeSourceKey(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,2L,null,501L,"v"))).hasMessageContaining("来源字段");
|
||||
}
|
||||
private static DispatchPalletCandidate row(long plan,long pallet,String customer){return new DispatchPalletCandidate().planPalletId(plan).warehousePalletId(pallet).customerId(customer).overseasWarehouseId("9").sourceStage("RECEIVED").packageCount(3).orderNo("ORDER-"+plan).palletNo("PALLET-"+pallet);}
|
||||
/** 换标生产者没有计划托盘别名,始终提供真实申请业务号与实托。 */
|
||||
private static DispatchPalletCandidate relabelRow(long pallet){return row(0,pallet,"101").planPalletId(null).sourceStage("RELABEL").orderRefs("RL-1");}
|
||||
}
|
||||
|
||||
+15
-6
@@ -26,7 +26,7 @@ class DispatchWholeSourcePersistenceTest {
|
||||
@Test void rejectsChangedFactsHeldSourcesForeignPalletAndWarehouse() throws Exception {
|
||||
var fixture=new Sources();var selected=fixture.selections();
|
||||
fixture.f.db.update("UPDATE ll_inventory_position SET version=version+1,available_packages=available_packages+1 WHERE id=1");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本已变化");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("真实可用库存来源");
|
||||
fixture.f.db.update("UPDATE ll_inventory_position SET version=1,available_packages=3 WHERE id=1");
|
||||
fixture.f.db.update("UPDATE ll_order SET detained=1 WHERE id=9002");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("暂扣");
|
||||
@@ -48,15 +48,16 @@ class DispatchWholeSourcePersistenceTest {
|
||||
Sources() throws Exception {
|
||||
f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5);
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64))");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)");
|
||||
f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,customer_id VARCHAR(64))");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,pallet_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)");
|
||||
for(String column:List.of("order_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT","warehouse_pallet_id BIGINT"))f.db.execute("ALTER TABLE ll_inbound_stock_receipt ADD "+column);
|
||||
for(long id:List.of(1L,2L)) {
|
||||
var row=f.candidates.get(id);f.db.update("INSERT INTO ll_order(id,customer_id,order_no)VALUES(?,?,?)",row.orderId(),row.customerId(),row.orderNo());
|
||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?)",id,row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",id,id);
|
||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?,?)",id,row.customerId(),"9",row.orderId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?,?)",id,id,row.palletNo(),row.warehousePalletId(),row.customerId());
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?)",id,id);
|
||||
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),"9",row.packageCount());
|
||||
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount());
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
|
||||
}
|
||||
var plans=mock(DispatchCandidateMapper.class);
|
||||
@@ -65,6 +66,14 @@ class DispatchWholeSourcePersistenceTest {
|
||||
sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
|
||||
var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper);
|
||||
ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
var relabel=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
org.mockito.stubbing.Answer<com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot> snapshot=call->{
|
||||
List<Long> pallets=call.getArgument(1);
|
||||
var positions=f.db.query("SELECT * FROM ll_inventory_position ORDER BY id",(rs,n)->new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Position(rs.getLong("id"),rs.getString("customer_id"),rs.getLong("balance_id"),rs.getLong("pallet_id"),rs.getLong("available_packages"),0,0,0,0,rs.getLong("version"))).stream().filter(p->pallets.contains(p.warehousePalletId())).toList();
|
||||
return new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),positions,"ORDINARY",true,List.of());
|
||||
};
|
||||
when(relabel.snapshot(anyString(),anyList())).thenAnswer(snapshot);when(relabel.lockedSnapshot(anyString(),anyList())).thenAnswer(snapshot);
|
||||
ReflectionTestUtils.setField(rules,"relabelSources",relabel);ReflectionTestUtils.setField(rules,"palletSerial",mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class));
|
||||
}
|
||||
/** GET与写锁内复用同一真实事实摘要。 */
|
||||
List<DispatchCommands.CargoSelection> selections(){return f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->new DispatchCommands.CargoSelection(
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class ReturnDispatchCandidatePersistenceTest {
|
||||
assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull();
|
||||
assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1);
|
||||
assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty();
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')");
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RETURN:6','ACTIVE',NULL,'another-draft')");
|
||||
assertThat(mapper.available("A", null, null, "this-draft")).isEmpty();
|
||||
db.update("DELETE FROM ll_dispatch_pallet_lock");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)");
|
||||
|
||||
+23
-9
@@ -10,6 +10,9 @@ import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
@@ -32,10 +35,10 @@ class ReturnCartonReturnPersistenceTest {
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
|
||||
var config=new MybatisConfiguration();
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
|
||||
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
|
||||
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchOrderMapper.class,DispatchItemMapper.class,
|
||||
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
|
||||
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
|
||||
mappers.forEach(config::addMapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
@@ -47,6 +50,7 @@ class ReturnCartonReturnPersistenceTest {
|
||||
table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));
|
||||
db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
createAggregateTable();
|
||||
db.execute("CREATE UNIQUE INDEX balance_scope ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)");
|
||||
db.execute("CREATE UNIQUE INDEX receipt_source ON ll_return_carton_stock_receipt(customer_id,source_pick_id,sku_code)");
|
||||
db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(128),business_order_no VARCHAR(128),outbound_order_no VARCHAR(128),business_type VARCHAR(128),delta_packages BIGINT,source_event_id VARCHAR(128),occurred_at TIMESTAMP)");
|
||||
@@ -59,9 +63,9 @@ class ReturnCartonReturnPersistenceTest {
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code) VALUES(5,'C',2,500,'RL-1','ONT8')");
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no) VALUES(10,'C','DP-1')");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','RETURN_RELABEL','92')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id) VALUES(102,'C',10)");
|
||||
db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,status,stage,record_version,cargo_revision,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,overseas_warehouse_id,departed_at) VALUES(10,'DP-1','ACTIVE','DELIVERING',0,0,1,1,1,1,1,'92',?)",LocalDateTime.now().minusHours(1));
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,destination_type,package_count,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','FBA',1,'RETURN_RELABEL','92')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,task_no,status,warehouse_id) VALUES(102,10,'PDA-DP-1','COMPLETED','92')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,8)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(105,'C',103,100,1)");
|
||||
db.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id,approved_return_packages,received_packages) VALUES(9,'C',1,5,101,1,0)");
|
||||
@@ -98,9 +102,8 @@ class ReturnCartonReturnPersistenceTest {
|
||||
/** 完成命令真实落账后才推进派送并供车次结案消费,同键重放不重复恢复库存。 */
|
||||
@Test void realExceptionCompletionAndTripCompletionConsumeTheNativeReceipt() {
|
||||
db.update("UPDATE ll_exception_order SET exception_type='RETURN',delivery_no='DL-1',version=0 WHERE id=1");
|
||||
db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3");
|
||||
db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=1,overseas_warehouse_id='92' WHERE id=10");
|
||||
db.update("UPDATE ll_outbound_order SET package_count=1 WHERE id=2");
|
||||
db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',overseas_warehouse_id='92',destination_type='FBA',destination_warehouse_code='ONT8',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3");
|
||||
db.update("UPDATE ll_outbound_order SET package_count=1,overseas_warehouse_id='92' WHERE id=2");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no) VALUES(100,'C','LM-1','DL-1')");
|
||||
var business=business();
|
||||
var request=new ExceptionOrderRequests.Complete(0,"native-complete","实收原箱",List.of(999L),List.of(receipt()));
|
||||
@@ -109,9 +112,14 @@ class ReturnCartonReturnPersistenceTest {
|
||||
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED");
|
||||
assertThat(tx.execute(s->business.complete("C","OP","EX-1",request)).replayed()).isTrue();
|
||||
assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1);
|
||||
var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(map(DispatchOrderMapper.class),map(DispatchPdaTaskMapper.class),map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DispatchItemMapper.class),map(DispatchPdaPickMapper.class),mock(com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService.class),map(DeliveryOperationMapper.class));
|
||||
var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
|
||||
var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(
|
||||
new DispatchAggregateRepository(map(DispatchAggregateMapper.class),map(DispatchItemMapper.class),map(DispatchReadMapper.class)),
|
||||
map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DeliveryOperationMapper.class),
|
||||
new DispatchCargoSourceRepository(map(DispatchFeeCargoMapper.class)),workflow,new ObjectMapper().findAndRegisterModules());
|
||||
var outcome=tx.execute(s->fleet.completion("DP-1",true));
|
||||
assertThat(outcome.blockReason()).isNull();assertThat(outcome.outcomes()).singleElement().satisfies(row->{assertThat(row.returnedPackages()).isEqualTo(1);assertThat(row.remainingPackages()).isZero();});
|
||||
verifyNoInteractions(workflow);
|
||||
}
|
||||
/** 不制造入仓单,从真实换标主单经过请求校验、标记、受理、回仓直至派送结案。 */
|
||||
@Test void nativeMasterWithoutInboundCanMarkAndCompleteAfterBeanValidation() throws Exception {
|
||||
@@ -191,6 +199,12 @@ class ReturnCartonReturnPersistenceTest {
|
||||
private ExceptionOrderRequests.ReturnReceipt receipt(){return new ExceptionOrderRequests.ReturnReceipt(5L,null,1,501L,"P-NEW",301L,"LOC-301",101L);}
|
||||
/** 根据实体类型建立测试表;正式DDL另由Schema合同验证。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(8000)";}
|
||||
/** 当前整车使用独立聚合模型,测试表不引入旧客户头部或旧主单实体。 */
|
||||
private void createAggregateTable() {
|
||||
var columns=Arrays.stream(DispatchAggregate.class.getDeclaredFields()).filter(f->!f.isSynthetic()&&!java.lang.reflect.Modifier.isStatic(f.getModifiers()))
|
||||
.map(f->f.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)+" "+sqlType(f.getType())+(f.getName().equals("id")?" PRIMARY KEY":"")).toList();
|
||||
db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",columns)+")");
|
||||
}
|
||||
/** 获取隔离库的真实Mapper代理。 */
|
||||
private <T>T map(Class<T> type){return session.getMapper(type);}
|
||||
}
|
||||
|
||||
+4
@@ -57,6 +57,10 @@ class FleetHandoverPdaQueryTest {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A"));
|
||||
verifyNoInteractions(history, handovers);
|
||||
}
|
||||
@Test void sameWarehouseEmptyEvidenceCannotBorrowTheTruckHeaderQuantity() {
|
||||
when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",30,List.of()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));verifyNoInteractions(history,handovers);
|
||||
}
|
||||
@Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() {
|
||||
var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value();
|
||||
assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
|
||||
|
||||
-1
@@ -98,7 +98,6 @@ class FleetHandoverTransactionTest {
|
||||
assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId());
|
||||
assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo());
|
||||
verify(dispatches, times(1)).handover(any(), any());
|
||||
verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any());
|
||||
assertEquals(1, repository.count(1));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -46,13 +46,13 @@ class FleetPickupServiceTest {
|
||||
source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null);
|
||||
var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other);
|
||||
var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2");
|
||||
assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any());
|
||||
assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).createPickup(anyString(),any());
|
||||
var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3");
|
||||
assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择");
|
||||
}
|
||||
@Test void alreadyScheduledSourceIsNotScheduledAgain() {
|
||||
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
|
||||
assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any());
|
||||
assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).createPickup(anyString(),any());
|
||||
}
|
||||
@Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() {
|
||||
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 真实主单/Fleet/预订Mapper及事务;沿GET route所用应用读取检验站序,不调用环境HTTP。 */
|
||||
class FleetDispatchRoutePersistenceTest {
|
||||
@Test void lastPalletRemovalPrunesOnlyItsStopPreservesOrderAndAppendsNewDestinations() throws Exception {
|
||||
var s=new Scenario();var f=s.f;
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-prune",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5),f.source(3,"103",503,"SEA1",4)))));
|
||||
var origin=s.route(created.dispatchNo()).stops().get(0);
|
||||
var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"route-order",List.of("FBA:SEA1","FBA:ONT8","FBA:LAX9"),"确认卸货顺序")));
|
||||
long item=s.item(1);var request=new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-last-ont8","移出本目的地最后一托");
|
||||
var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request));
|
||||
assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9");
|
||||
var added=f.tx.execute(t->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(removed.recordVersion(),"append-new-destination",List.of(f.source(4,"104",504,"ABE2",2)),List.of())));
|
||||
assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9","FBA:ABE2");
|
||||
assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin);
|
||||
var beforeReplay=s.state(created.dispatchNo());var replay=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(removed.recordVersion());assertThat(s.state(created.dispatchNo())).isEqualTo(beforeReplay);assertThat(s.route(created.dispatchNo()).recordVersion()).isEqualTo(added.recordVersion());
|
||||
}
|
||||
|
||||
@Test void anotherPalletAtTheSameDestinationKeepsTheStopAndAnEmptyTruckKeepsOnlyItsOrigin() throws Exception {
|
||||
var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-same-destination",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"ONT8",5)))));
|
||||
var origin=s.route(created.dispatchNo()).stops().get(0);String trip=s.trip();
|
||||
var preview=f.fleet.capacityPlan(trip);var measurements=preview.cargo().stream().map(c->new FleetExecutionModels.Measurement(c.warehousePalletId(),1,BigDecimal.TEN,BigDecimal.ONE,1)).toList();
|
||||
f.tx.execute(t->f.fleet.confirmCapacityPlan(trip,f.actor.operatorId(),new FleetExecutionModels.Confirm(preview.recordVersion(),preview.manifestHash(),measurements,"实际计划容量")));
|
||||
assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNotNull();
|
||||
var first=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-first-same-destination","移出一托")));
|
||||
assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8");assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNull();
|
||||
var empty=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(first.recordVersion(),"remove-all-cargo","移出最后一托")));
|
||||
assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9");assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin);assertThat(empty.counts().packageCount()).isZero();assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings={"CONFIRMED","RECEIVED","BOUND"})
|
||||
void activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String status) throws Exception {
|
||||
var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-booking-"+status,List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));
|
||||
var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"booking-stop-order",List.of("FBA:ONT8","FBA:LAX9"),"原承诺站序")));
|
||||
s.booking(status,"FBA:ONT8");var original=s.route(created.dispatchNo()).stops();
|
||||
var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-booked-destination","保留有效外接卸货点")));
|
||||
assertThat(s.route(created.dispatchNo()).stops()).isEqualTo(original);
|
||||
f.db.update("UPDATE ll_fleet_external_booking SET status='CANCELLED'");
|
||||
f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(removed.recordVersion(),"remove-after-booking-cancel","外接已取消后移出剩余货物")));
|
||||
assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9");
|
||||
}
|
||||
|
||||
@Test void missingRequiredBookingStopRejectsTheWholeRemovalAndDoesNotInventAStop() throws Exception {
|
||||
var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-missing-booking",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));
|
||||
s.booking("CONFIRMED","FBA:UNSUPPORTED");var before=s.state(created.dispatchNo());
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"reject-missing-booking-stop","外接目的地不完整")))).hasMessageContaining("已确认外接预订");
|
||||
assertThat(s.state(created.dispatchNo())).isEqualTo(before);assertThat(s.keys(created.dispatchNo())).doesNotContain("FBA:UNSUPPORTED");
|
||||
}
|
||||
|
||||
@Test void failureAfterTripRouteWriteRollsBackMainClaimsQuantitiesAndLedgerThenTheSameKeyCanSucceed() throws Exception {
|
||||
var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-write-rollback",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));
|
||||
long item=s.item(2);var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"route-failed-original","实际事务回滚");var before=s.state(created.dispatchNo());
|
||||
f.db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD CONSTRAINT reject_quantity_three CHECK(package_count<>3)");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request))).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertThat(s.state(created.dispatchNo())).isEqualTo(before);
|
||||
f.db.execute("ALTER TABLE ll_fleet_trip_dispatch DROP CONSTRAINT reject_quantity_three");
|
||||
var saved=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request));assertThat(saved.replayed()).isFalse();assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8");
|
||||
var after=s.state(created.dispatchNo());assertThat(f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)).replayed()).isTrue();assertThat(s.state(created.dispatchNo())).isEqualTo(after);
|
||||
}
|
||||
|
||||
private record State(long version,int packages,String cargoHash,String review,long fleetVersion,String stops,String plan,int bindingPackages,long items,long activeClaims,long commands,long history,long fleetHistory) { }
|
||||
private static final class Scenario {
|
||||
final Fixture f=new Fixture();final FleetBookingMapper bookings;
|
||||
Scenario() throws Exception {
|
||||
f.session.getConfiguration().addMapper(FleetBookingMapper.class);bookings=f.session.getMapper(FleetBookingMapper.class);
|
||||
var table=TableInfoHelper.getTableInfo(FleetBookingEntity.class);var columns=new ArrayList<String>();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:table.getFieldList())columns.add(field.getColumn()+" "+sqlType(field.getPropertyType()));
|
||||
f.db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
var reservations=new FleetBookingReservations(bookings,f.json);ReflectionTestUtils.setField(f.nativeFleetService,"bookingReservations",reservations);ReflectionTestUtils.setField(f.fleet,"bookingReservations",reservations);
|
||||
}
|
||||
long item(long plan){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,plan);}
|
||||
String trip(){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);}
|
||||
DispatchWorkModels.Route route(String number){return f.tx.execute(t->f.service.route(f.actor,number));}
|
||||
List<String> keys(String number){return route(number).stops().stream().map(DispatchWorkModels.Stop::destinationKey).toList();}
|
||||
/** 预订是本切片的持久化输入;未声称执行原外接确认流程。 */
|
||||
void booking(String status,String destination) throws Exception {
|
||||
var row=new FleetBookingEntity();row.setId(901L);row.setTripId(f.db.queryForObject("SELECT id FROM ll_fleet_trip",Long.class));row.setTripNo(trip());row.setBookingNo("BOOKING-ROUTE");row.setCustomerId("201");row.setStatus(status);row.setDestinationKey(destination);row.setDestinationLabel(destination);row.setRecordVersion(0L);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());
|
||||
row.setCargoJson(f.json.writeValueAsString(new FleetBookingModels.Cargo(destination,"测试外接货",1,1,BigDecimal.ONE,BigDecimal.ONE,null,null,null,null,null,null)));bookings.insert(row);
|
||||
}
|
||||
State state(String number){var root=f.repository.find(number);return new State(root.getRecordVersion(),root.getPackageCount(),root.getCargoHash(),root.getDispatchReviewJson(),f.db.queryForObject("SELECT record_version FROM ll_fleet_trip",Long.class),f.db.queryForObject("SELECT stops_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class),count("ll_dispatch_item"),f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class),count("ll_dispatch_command"),count("ll_dispatch_history"),count("ll_fleet_trip_history"));}
|
||||
long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
|
||||
static String sqlType(Class<?> type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==BigDecimal.class?"DECIMAL(18,6)":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(8000)";}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -19,6 +19,7 @@ class FleetTripApiContractTest {
|
||||
for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map<?,?>)spec.get("paths"));
|
||||
String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
|
||||
module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql"));
|
||||
module += Files.readString(Path.of("../iam/src/main/resources/db/phase5-delivery-permissions.sql"));
|
||||
String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
int count = 0;
|
||||
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) {
|
||||
@@ -46,7 +47,7 @@ class FleetTripApiContractTest {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertThat(count).isEqualTo(54);
|
||||
assertThat(count).isEqualTo(46);
|
||||
verifyRefs(document, document);
|
||||
verifyRefs(preparation, preparation);
|
||||
verifyRefs(pool, pool);
|
||||
|
||||
+5
-4
@@ -4,7 +4,8 @@ import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixt
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.service.VehicleService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
@@ -51,7 +52,7 @@ class FleetTripPersistenceTest {
|
||||
var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue();
|
||||
var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约");
|
||||
var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED");
|
||||
assertThat(f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request))).isEqualTo(complete);
|
||||
FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete);
|
||||
assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull();
|
||||
var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束");
|
||||
var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull();
|
||||
@@ -75,14 +76,14 @@ class FleetTripPersistenceTest {
|
||||
private static String trip(Fixture f){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);}
|
||||
/** 已发运起始事实作为本切片fixture;客户单据实际Mapper读取,未模拟仓储查询成功。 */
|
||||
private static final class CompletedCargo {
|
||||
final Fixture f;final VehicleService vehicleService=mock(VehicleService.class);final FleetTripCompletionService completion;
|
||||
final Fixture f;final VehicleApplicationService vehicleService=mock(VehicleApplicationService.class);final FleetTripCompletionService completion;
|
||||
CompletedCargo() throws Exception {
|
||||
f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3);
|
||||
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
|
||||
f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
|
||||
var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
|
||||
var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery);
|
||||
completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,vehicleService,f.json);
|
||||
completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -50,7 +50,8 @@ public class VehicleFulfillmentQueryTest {
|
||||
jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'");
|
||||
jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT UNIQUE)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))");
|
||||
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)");
|
||||
@@ -61,8 +62,9 @@ public class VehicleFulfillmentQueryTest {
|
||||
when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
|
||||
service = new VehicleFulfillmentService(vehicles, mapper, JSON);
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')");
|
||||
delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,11,'CUSTOMER-A'),(2,11,'CUSTOMER-B')");
|
||||
delivery(21,11,"CUSTOMER-A"); delivery(22,11,"CUSTOMER-B");
|
||||
}
|
||||
private void delivery(long deliveryId, long dispatchId, String customer) {
|
||||
jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')",
|
||||
@@ -72,7 +74,7 @@ public class VehicleFulfillmentQueryTest {
|
||||
var trip = new HashMap<String,Object>();
|
||||
if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); }
|
||||
if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); }
|
||||
String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12"))));
|
||||
String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"))));
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot);
|
||||
}
|
||||
@Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() {
|
||||
@@ -108,7 +110,7 @@ public class VehicleFulfillmentQueryTest {
|
||||
assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN");
|
||||
assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7");
|
||||
assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8");
|
||||
assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12");
|
||||
assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11");
|
||||
assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z");
|
||||
assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
|
||||
}
|
||||
@@ -127,14 +129,14 @@ public class VehicleFulfillmentQueryTest {
|
||||
jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'");
|
||||
String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"),
|
||||
"carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")),
|
||||
"cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12")))));
|
||||
"cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-11"), Map.of("dispatchNo", "DP-11")))));
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot);
|
||||
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2);
|
||||
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records())
|
||||
.extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22");
|
||||
assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> {
|
||||
assertThat(change.relationship()).isEqualTo("REPLACED_IN");
|
||||
assertThat(change.dispatchNos()).containsExactly("DP-12");
|
||||
assertThat(change.dispatchNos()).containsExactly("DP-11");
|
||||
});
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22");
|
||||
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1);
|
||||
|
||||
+17
-12
@@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class InboundPlanDispatchStatusPersistenceTest {
|
||||
private JdbcTemplate db;
|
||||
private InboundPlanQueryMapper mapper;
|
||||
private com.baiyue.lastleg.mapper.LastlegOrderMapper orders;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
@@ -31,30 +32,33 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)");
|
||||
db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')");
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(InboundPlanQueryMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(data);
|
||||
factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundPlanQueryMapper.class);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
mapper = session.getMapper(InboundPlanQueryMapper.class);
|
||||
orders = session.getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledDispatchRetainsHistoryButDoesNotMarkOrFilterTheInboundAsScheduled() {
|
||||
dispatch(10, "A", "CANCELLED", "A");
|
||||
dispatch(10, "CANCELLED", "BEFORE_LOADING", "A");
|
||||
assertStatus("UNSCHEDULED");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacementDispatchCountsOnceAndCancellingItRestoresUnscheduled() {
|
||||
dispatch(10, "A", "CANCELLED", "A");
|
||||
dispatch(11, "A", "PENDING_DEPARTURE", "A");
|
||||
dispatch(12, "A", "PICKING", "A");
|
||||
dispatch(10, "CANCELLED", "BEFORE_LOADING", "A");
|
||||
dispatch(11, "ACTIVE", "BEFORE_LOADING", "A");
|
||||
dispatch(12, "ACTIVE", "LOADING", "A");
|
||||
assertStatus("SCHEDULED");
|
||||
db.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertStatus("UNSCHEDULED");
|
||||
@@ -62,16 +66,15 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
|
||||
@Test
|
||||
void departedAndCompletedDispatchesRemainScheduled() {
|
||||
dispatch(10, "A", "DEPARTED", "A");
|
||||
dispatch(10, "ACTIVE", "DELIVERING", "A");
|
||||
assertStatus("SCHEDULED");
|
||||
db.update("UPDATE ll_dispatch_order SET status='COMPLETED'");
|
||||
db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'");
|
||||
assertStatus("SCHEDULED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherCustomerOrMissingDispatchCannotMarkThisInboundScheduled() {
|
||||
dispatch(10, "B", "PENDING_DEPARTURE", "B");
|
||||
dispatch(11, "B", "PENDING_DEPARTURE", "A");
|
||||
dispatch(10, "ACTIVE", "BEFORE_LOADING", "B");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(12,'A',999,3)");
|
||||
assertStatus("UNSCHEDULED");
|
||||
assertThat(mapper.rows("B", query(null), 0, 20)).isEmpty();
|
||||
@@ -106,12 +109,14 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
assertThat(remaining.appointmentNo()).isEqualTo("AP-FIRST");assertThat(remaining.appointmentStatus()).isEqualTo("SUCCESS");
|
||||
}
|
||||
|
||||
private void dispatch(long id, String owner, String status, String itemOwner) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status);
|
||||
private void dispatch(long id, String status, String stage, String itemOwner) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
|
||||
}
|
||||
|
||||
private void assertStatus(String status) {
|
||||
assertThat(orders.findDispatchStatuses(java.util.List.of(3L)))
|
||||
.extracting(com.baiyue.lastleg.mapper.LastlegOrderMapper.DispatchStatusRow::dispatchStatus).containsExactly(status);
|
||||
for (String customer : new String[]{null, "A"}) {
|
||||
assertThat(mapper.rows(customer, query(null), 0, 20))
|
||||
.extracting(InboundPlanListResponse::dispatchStatus).containsExactly(status);
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class InboundPlanQueryMapperContractTest {
|
||||
@Test
|
||||
@@ -20,7 +21,7 @@ class InboundPlanQueryMapperContractTest {
|
||||
assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId"));
|
||||
assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}"));
|
||||
assertTrue(sql.contains("di.customer_id=p.customer_id AND di.order_id=l.order_id"));
|
||||
assertTrue(sql.contains("dispatch.customer_id=di.customer_id"));
|
||||
assertFalse(sql.contains("dispatch.customer_id"));
|
||||
assertTrue(sql.contains("dispatch.status<>'CANCELLED'"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -240,7 +240,7 @@ class FbaAppointmentPersistenceTest {
|
||||
@Bean FbaAppointmentTransportPort receivingTransport(){
|
||||
var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of());
|
||||
when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenReturn(scope);
|
||||
when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
|
||||
when(port.apply(any(),anyList(),anyString(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
|
||||
}
|
||||
@Bean FbaAppointmentWorkPort receivingWork(){
|
||||
var port=mock(FbaAppointmentWorkPort.class);
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ class FbaAppointmentRepartitionPersistenceTest {
|
||||
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
var original=service.createFromSources("operator",create("create"));
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(10,'A','DRAFT')");db.update("INSERT INTO ll_dispatch_item VALUES(1,'A',10)");
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status) VALUES(10,'ACTIVE')");db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id) VALUES(11,'A',10,12)");
|
||||
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,11,12,?,'A','ORDER:1','FBA:ONT8',1)",original.batchKey());
|
||||
assertThrows(ResponseStatusException.class,()->service.repartition("operator",split(original,"blocked")));
|
||||
db.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE id=10");
|
||||
|
||||
+12
-12
@@ -33,7 +33,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertFalse(f.store.hasPending(original.batchKey(),"A"));
|
||||
assertEquals(1,f.receiving.context(original.batchKey()).pendingChanges().size());
|
||||
f.assertAllocations(80,160,40);
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
var withdrawn=f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw",0,"WITHDRAW",null,TRANSPORT));
|
||||
assertEquals("WITHDRAWN",withdrawn.status());
|
||||
assertFalse(f.store.hasPending(original.batchKey(),"B"));
|
||||
@@ -65,7 +65,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(List.of(20,40,100),destinations.stream().flatMap(b->b.items().stream()).filter(i->i.customerId().equals("B")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList());
|
||||
f.assertAllocations(80,160,40);
|
||||
assertEquals(0,f.db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class));
|
||||
verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString());
|
||||
verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString(),eq("partial-confirm"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class FbaReceivingPersistenceTest {
|
||||
conflict(()->f.receiving.resolve(OPERATOR,first.changeKey(),new Resolve("same-resolve",0,"WITHDRAW",null,TRANSPORT)));
|
||||
assertEquals(2,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
|
||||
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class));
|
||||
verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
|
||||
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='retry-resolve'",Integer.class));
|
||||
f.assertAllocations(80,160,40);
|
||||
doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString());
|
||||
doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
assertEquals("APPLIED",f.confirm(pending.changeKey(),"retry-resolve").status());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
@@ -171,7 +171,7 @@ class FbaReceivingPersistenceTest {
|
||||
conflict(()->f.confirm(pending.changeKey(),"stale-batch"));
|
||||
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
|
||||
assertEquals(280,total(f.batches.detail(original.appointmentNo())));f.assertAllocations(80,160,40);
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ class FbaReceivingPersistenceTest {
|
||||
conflict(()->f.confirm(pending.changeKey(),"stale-agreement"));
|
||||
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
|
||||
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(original,f.batches.detail(original.appointmentNo()));
|
||||
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
|
||||
f.assertAllocations(80,160,40);
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request));
|
||||
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT)));
|
||||
verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList());
|
||||
verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"),anyString());
|
||||
verify(f.transport,never()).lockAndInspect(anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
|
||||
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-confirm'",Integer.class));
|
||||
verify(f.work,never()).seedFollowup(any(),any(),any(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
}
|
||||
@@ -439,7 +439,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-proposal'",Integer.class));
|
||||
assertEquals(original,f.batches.detail(original.appointmentNo()));
|
||||
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
}
|
||||
@@ -462,7 +462,7 @@ class FbaReceivingPersistenceTest {
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -87,7 +87,8 @@ class InboundPlanPaginationTest {
|
||||
assertThat(sql).contains("p.customer_id=#{customerId}", "o.customer_id=p.customer_id");
|
||||
assertThat(sql).doesNotContain("a2.customer_id=#{customerId}");
|
||||
assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id",
|
||||
"dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'");
|
||||
"dispatch.id=di.dispatch_order_id", "dispatch.status<>'CANCELLED'");
|
||||
assertThat(sql).doesNotContain("dispatch.customer_id");
|
||||
assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode",
|
||||
"o.overseas_warehouse_name overseasWarehouseName",
|
||||
"p.overseas_warehouse_id=#{q.overseasWarehouseId}");
|
||||
|
||||
+14
-3
@@ -61,17 +61,28 @@ class AppointmentWorkDomainMapperTest {
|
||||
var sessions=receiving.sessions(List.of("1","2"));assertEquals(2,sessions.size());
|
||||
assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId());
|
||||
try(var sql=session.getConnection().createStatement()) {
|
||||
sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')");
|
||||
sql.execute("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,cargo_hash) VALUES(501,'D-1',801,'W','ACTIVE','BEFORE_LOADING','HASH'),(502,'D-2',802,'W','CANCELLED','BEFORE_LOADING','HASH'),(503,'D-3',803,'W','ACTIVE','BEFORE_LOADING','HASH')");
|
||||
sql.execute("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,order_id,pallet_no,source_key,source_version,source_snapshot_json,source_stage,package_count) VALUES(601,501,'A',701,101,'P-701','PLAN:701','HASH','{}','RECEIVED',4),(602,501,'B',702,102,'P-702','PLAN:702','HASH','{}','RECEIVED',6),(603,502,'B',703,102,'P-703','PLAN:703','HASH','{}','RECEIVED',2),(604,503,'B',704,102,'P-704','PLAN:704','HASH','{}','RECEIVED',3)");
|
||||
sql.execute("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by) VALUES(1,'AP-1','W','ONT8','operator'),(2,'AP-2','W','ONT8','operator')");
|
||||
sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)");
|
||||
}
|
||||
session.clearCache();
|
||||
var references=dispatch.references(List.of(501L,503L));
|
||||
assertEquals(2,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages());
|
||||
assertEquals("FBA_APPOINTMENT:2",references.get(1).batchKey());assertEquals("AP-2",references.get(1).batchNo()); // Wrong-customer rows on dispatch 501 must not leak into current references.
|
||||
// 同车两客户均由真实明细关联;主单没有单一客户归属。
|
||||
assertEquals(3,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages());
|
||||
assertEquals(501L,references.get(1).dispatchId());assertEquals("B",references.get(1).customerId());assertEquals("AP-1",references.get(1).batchNo());assertEquals(6,references.get(1).packages());
|
||||
assertEquals("FBA_APPOINTMENT:2",references.get(2).batchKey());assertEquals("AP-2",references.get(2).batchNo());
|
||||
assertEquals(4,dispatch.allocated("FBA_APPOINTMENT:1","A","ORDER:101","FBA:ONT8",503));
|
||||
assertEquals(6,dispatch.allocated("FBA_APPOINTMENT:1","B","ORDER:102","FBA:ONT8",503));
|
||||
var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size());
|
||||
assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
|
||||
assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
|
||||
// 错客户或错托盘的分配不能借同一主单混入当前明细引用。
|
||||
try(var sql=session.getConnection().createStatement()) {
|
||||
sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(501,602,999,'FBA_APPOINTMENT:1','B','ORDER:FOREIGN','FBA:ONT8',7)");
|
||||
}
|
||||
session.clearCache();
|
||||
assertEquals(references,dispatch.references(List.of(501L,503L)));
|
||||
var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -19,10 +19,10 @@ class DispatchShipmentSourceQueryTest {
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL");
|
||||
execute(connection, "CREATE TABLE ll_inventory_ledger (id BIGINT PRIMARY KEY, customer_id VARCHAR, position_id BIGINT, overseas_warehouse_id VARCHAR, sku_code VARCHAR, business_type VARCHAR, delta_packages BIGINT, business_order_no VARCHAR, outbound_order_no VARCHAR, idempotency_key VARCHAR)");
|
||||
execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)");
|
||||
execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)");
|
||||
execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, warehouse_id VARCHAR, dispatch_order_id BIGINT UNIQUE)");
|
||||
execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)");
|
||||
execute(connection, "CREATE TABLE ll_inventory_operation (id BIGINT PRIMARY KEY, customer_id VARCHAR, operation_type VARCHAR, status VARCHAR, idempotency_key VARCHAR)");
|
||||
execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)");
|
||||
execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'WH-1',1)");
|
||||
execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')");
|
||||
return connection;
|
||||
}
|
||||
@@ -88,9 +88,10 @@ class DispatchShipmentSourceQueryTest {
|
||||
execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='pick-1', packages=9");
|
||||
assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1"));
|
||||
execute(connection, "UPDATE ll_dispatch_pda_pick SET packages=8");
|
||||
execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='200'");
|
||||
execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='200'");
|
||||
assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1"));
|
||||
execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='100',warehouse_id='WH-2'");
|
||||
execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='100'");
|
||||
execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-2'");
|
||||
assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1"));
|
||||
execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-1',dispatch_order_id=2");
|
||||
assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1"));
|
||||
@@ -103,6 +104,22 @@ class DispatchShipmentSourceQueryTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 一车一任务承载两客户,发运恢复只认各自实际扫码和完成的库存命令。 */
|
||||
@Test
|
||||
void sharedTaskPreservesIndependentCustomerShipmentSources() throws Exception {
|
||||
try (Connection connection = database()) {
|
||||
scan(connection); transfer(connection);
|
||||
execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (22,'200',11,32,42,5,'pick-2')");
|
||||
execute(connection, "INSERT INTO ll_inventory_operation VALUES (72,'200','SHIP','COMPLETED','DISPATCH:DS-1:32:pick-2:SHIP')");
|
||||
execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'200',42,'WH-1','SKU-2','SHIP',-5,'DS-1','DS-1','DISPATCH:DS-1:32:pick-2:SHIP:72')");
|
||||
execute(connection, "INSERT INTO ll_outbound_order VALUES (62,'200','OB-2','WH-1','OUTBOUNDED','DISPATCH_DELIVERY','DS-1',1)");
|
||||
assertEquals(8, shipped(connection,"100",41,"OB-1","SKU-1"));
|
||||
assertEquals(5, shipped(connection,"200",42,"OB-2","SKU-2"));
|
||||
assertEquals(0, shipped(connection,"100",42,"OB-2","SKU-2"));
|
||||
assertEquals(0, shipped(connection,"200",41,"OB-1","SKU-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void independentOutboundUsesItsOwnNumberAndDoesNotAcceptADispatchAlias() throws Exception {
|
||||
try (Connection connection = database()) {
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ class InventoryHoldConcurrencyPersistenceTest {
|
||||
}
|
||||
private void scan(Runnable afterStock) {
|
||||
tx.executeWithoutResult(ignored -> {
|
||||
attribution.preparePicking("C1",21,null);
|
||||
attribution.preparePicking("C1",21);
|
||||
inventory.allocate("C1","picker",move(21,5,"scan-allocate"));
|
||||
inventory.pick("C1","picker",move(21,5,"scan-pick"));
|
||||
inventory.ship("C1","picker",move(21,5,"scan-ship"));
|
||||
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
package com.baiyue.lastleg.inventory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import org.h2.api.Trigger;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.IllegalTransactionStateException;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 实际 Mapper、H2 行锁及 Spring 事务验证共同串行点,不以 mock 调用顺序代替并发证据。 */
|
||||
public class PhysicalPalletSerialPersistenceTest {
|
||||
private static final Map<String, List<Long>> INSERT_ORDER = new ConcurrentHashMap<>();
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private PhysicalPalletSerialRepository repository;
|
||||
private PhysicalPalletSerialService service;
|
||||
private ExecutorService workers;
|
||||
private String observationKey;
|
||||
|
||||
/** 隔离表严格只有实托主键与首次创建时间,触发器仅记录实际数据库插入顺序。 */
|
||||
@BeforeEach
|
||||
void prepare() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:physical-serial-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_physical_pallet_guard (
|
||||
warehouse_pallet_id BIGINT PRIMARY KEY,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))
|
||||
""");
|
||||
observationKey = "PHYSICAL_ORDER_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
|
||||
INSERT_ORDER.put(observationKey, Collections.synchronizedList(new ArrayList<>()));
|
||||
jdbc.execute("CREATE TRIGGER " + observationKey + " BEFORE INSERT ON ll_physical_pallet_guard "
|
||||
+ "FOR EACH ROW CALL '" + GuardInsertOrderTrigger.class.getName() + "'");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(PhysicalPalletSerialMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds);
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(PhysicalPalletSerialMapper.class);
|
||||
repository = new PhysicalPalletSerialRepository(mapper);
|
||||
var manager = new DataSourceTransactionManager(ds);
|
||||
transaction = new TransactionTemplate(manager);
|
||||
var target = new PhysicalPalletSerialService(repository);
|
||||
var proxy = new ProxyFactory(target);
|
||||
proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
|
||||
service = (PhysicalPalletSerialService) proxy.getProxy();
|
||||
workers = Executors.newFixedThreadPool(2);
|
||||
}
|
||||
|
||||
/** 释放失败测试中的等待任务并移除本数据库的观测记录,避免跨用例共享状态。 */
|
||||
@AfterEach
|
||||
void cleanup() throws InterruptedException {
|
||||
if (workers != null) {
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS), "并发测试线程未退出");
|
||||
}
|
||||
if (observationKey != null) INSERT_ORDER.remove(observationKey);
|
||||
}
|
||||
|
||||
/** 尚无库存或运输占用的真实空实托首次使用也能建立共同串行行。 */
|
||||
@Test
|
||||
void firstUseOfEmptyPhysicalPalletCreatesGuard() {
|
||||
assertEquals(0, count());
|
||||
transaction.executeWithoutResult(status -> service.lock(List.of(901L)));
|
||||
assertEquals(List.of(901L), ids());
|
||||
assertNotNull(createdAt(901));
|
||||
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE TABLE_NAME='LL_PHYSICAL_PALLET_GUARD'", Integer.class));
|
||||
}
|
||||
|
||||
/** 没有关联真实实托的计划传空集合时不生成替代主键。 */
|
||||
@Test
|
||||
void emptyTargetSetDoesNotCreateSyntheticGuard() {
|
||||
transaction.executeWithoutResult(status -> {
|
||||
service.lock(List.of());
|
||||
service.lock(List.of());
|
||||
});
|
||||
assertEquals(0, count());
|
||||
assertTrue(INSERT_ORDER.get(observationKey).isEmpty());
|
||||
}
|
||||
|
||||
/** 重复 upsert 和重复取锁保留原首次创建时间,不刷新租期或附加归属。 */
|
||||
@Test
|
||||
void repeatedUsePreservesOriginalCreatedAt() {
|
||||
LocalDateTime original = LocalDateTime.of(2020, 1, 2, 3, 4, 5);
|
||||
jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id,created_at) VALUES(?,?)", 901, original);
|
||||
transaction.executeWithoutResult(status -> {
|
||||
service.lock(List.of(901L, 901L));
|
||||
service.lock(List.of(901L));
|
||||
});
|
||||
transaction.executeWithoutResult(status -> service.lock(List.of(901L)));
|
||||
assertEquals(1, count());
|
||||
assertEquals(original, createdAt(901));
|
||||
}
|
||||
|
||||
/** 触发器记录真实插入顺序,重复输入必须在 SQL 前去重并按数值升序执行。 */
|
||||
@Test
|
||||
void databaseObservesUniqueAscendingCreationOrder() {
|
||||
transaction.executeWithoutResult(status -> service.lock(List.of(303L, 101L, 202L, 101L, 303L)));
|
||||
assertEquals(List.of(101L, 202L, 303L), List.copyOf(INSERT_ORDER.get(observationKey)));
|
||||
assertEquals(List.of(101L, 202L, 303L), ids());
|
||||
}
|
||||
|
||||
/** 一次校验完整目标,非法及空身份不应让前面的合法身份先产生 SQL。 */
|
||||
@Test
|
||||
void invalidIdsAreRejectedBeforeAnyDatabaseInsert() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> transaction.executeWithoutResult(status -> service.lock(null)));
|
||||
for (List<Long> invalid : List.of(List.of(901L, 0L), List.of(901L, -1L), Arrays.asList(901L, null))) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> transaction.executeWithoutResult(status -> service.lock(invalid)));
|
||||
}
|
||||
assertEquals(0, count());
|
||||
assertTrue(INSERT_ORDER.get(observationKey).isEmpty());
|
||||
}
|
||||
|
||||
/** MANDATORY 对空集合也生效,不能在无外层事务时建立或短暂持有串行锁。 */
|
||||
@Test
|
||||
void missingOuterTransactionIsRejected() {
|
||||
assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of(901L)));
|
||||
assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of()));
|
||||
assertEquals(0, count());
|
||||
}
|
||||
|
||||
/** 即使绕过 Spring 代理直接调用实现,也不能在事务外取得瞬时自动提交锁。 */
|
||||
@Test
|
||||
void directInvocationCannotBypassTransactionRequirement() {
|
||||
var direct = new PhysicalPalletSerialService(repository);
|
||||
assertThrows(IllegalStateException.class, () -> direct.lock(List.of(901L)));
|
||||
assertEquals(0, count());
|
||||
}
|
||||
|
||||
/** 只读事务不能承担建行及持有业务串行锁的职责。 */
|
||||
@Test
|
||||
void readOnlyCallerCannotAcquireGuard() {
|
||||
var readOnly = new TransactionTemplate(transaction.getTransactionManager());
|
||||
readOnly.setReadOnly(true);
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> readOnly.executeWithoutResult(status -> service.lock(List.of(901L))));
|
||||
assertEquals(0, count());
|
||||
}
|
||||
|
||||
/** 第二连接对同一真实实托必须等待第一外层事务提交后才能进入。 */
|
||||
@Test
|
||||
@Timeout(30)
|
||||
void samePhysicalPalletSerializesConcurrentTransactions() throws Exception {
|
||||
jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id) VALUES(901)");
|
||||
var firstLocked = new CountDownLatch(1);
|
||||
var releaseFirst = new CountDownLatch(1);
|
||||
var firstSession = new AtomicInteger();
|
||||
Future<?> first = hold(List.of(901L), firstSession, firstLocked, releaseFirst);
|
||||
try {
|
||||
await(firstLocked);
|
||||
var secondSession = new AtomicInteger();
|
||||
var secondSessionReady = new CountDownLatch(1);
|
||||
Future<?> second = workers.submit(() -> transaction.executeWithoutResult(status -> {
|
||||
secondSession.set(sessionId());
|
||||
secondSessionReady.countDown();
|
||||
service.lock(List.of(901L));
|
||||
}));
|
||||
await(secondSessionReady);
|
||||
awaitGuardLockWait(secondSession.get(), firstSession.get(), second);
|
||||
assertFalse(first.isDone());
|
||||
releaseFirst.countDown();
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, count());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
/** 不相交真实实托可以在另一事务仍持锁时完成,不形成全表业务互斥。 */
|
||||
@Test
|
||||
@Timeout(30)
|
||||
void disjointPhysicalPalletsProceedWhileAnotherTransactionHoldsItsGuard() throws Exception {
|
||||
var firstLocked = new CountDownLatch(1);
|
||||
var releaseFirst = new CountDownLatch(1);
|
||||
var firstSession = new AtomicInteger();
|
||||
Future<?> first = hold(List.of(901L), firstSession, firstLocked, releaseFirst);
|
||||
try {
|
||||
await(firstLocked);
|
||||
Future<?> second = workers.submit(() -> transaction.executeWithoutResult(status -> {
|
||||
assertNotEquals(firstSession.get(), sessionId());
|
||||
service.lock(List.of(902L));
|
||||
}));
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(first.isDone());
|
||||
assertEquals(1, releaseFirst.getCount());
|
||||
assertEquals(List.of(902L), ids());
|
||||
releaseFirst.countDown();
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(List.of(901L, 902L), ids());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
/** 新行随第一事务回滚并释放阻塞,等待者随后为同实托重新建行取锁。 */
|
||||
@Test
|
||||
@Timeout(30)
|
||||
void rollbackReleasesWaitingTransactionAndDoesNotKeepRolledBackCreationTime() throws Exception {
|
||||
var firstLocked = new CountDownLatch(1);
|
||||
var releaseFirst = new CountDownLatch(1);
|
||||
var firstSession = new AtomicInteger();
|
||||
LocalDateTime rolledBackTime = LocalDateTime.of(2010, 1, 2, 3, 4, 5);
|
||||
Future<?> first = workers.submit(() -> transaction.executeWithoutResult(status -> {
|
||||
service.lock(List.of(903L));
|
||||
jdbc.update("UPDATE ll_physical_pallet_guard SET created_at=? WHERE warehouse_pallet_id=903", rolledBackTime);
|
||||
firstSession.set(sessionId());
|
||||
firstLocked.countDown();
|
||||
await(releaseFirst);
|
||||
status.setRollbackOnly();
|
||||
}));
|
||||
try {
|
||||
await(firstLocked);
|
||||
var secondSession = new AtomicInteger();
|
||||
var secondSessionReady = new CountDownLatch(1);
|
||||
Future<?> second = workers.submit(() -> transaction.executeWithoutResult(status -> {
|
||||
secondSession.set(sessionId());
|
||||
secondSessionReady.countDown();
|
||||
service.lock(List.of(903L));
|
||||
}));
|
||||
await(secondSessionReady);
|
||||
awaitGuardLockWait(secondSession.get(), firstSession.get(), second);
|
||||
releaseFirst.countDown();
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(List.of(903L), ids());
|
||||
assertNotEquals(rolledBackTime, createdAt(903));
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
/** 两个相反且重复的请求通过真实数据库完成,交集按同一数值顺序串行。 */
|
||||
@Test
|
||||
@Timeout(30)
|
||||
void overlappingReverseRequestsFinishWithOneGuardPerPhysicalPallet() throws Exception {
|
||||
var start = new CountDownLatch(1);
|
||||
Future<?> first = workers.submit(() -> {
|
||||
await(start);
|
||||
transaction.executeWithoutResult(status -> service.lock(List.of(905L, 904L, 906L, 904L)));
|
||||
});
|
||||
Future<?> second = workers.submit(() -> {
|
||||
await(start);
|
||||
transaction.executeWithoutResult(status -> service.lock(List.of(906L, 905L, 904L, 906L)));
|
||||
});
|
||||
start.countDown();
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(List.of(904L, 905L, 906L), ids());
|
||||
}
|
||||
|
||||
/** 第一调用方原事务持锁等待,不提交中间建行或借用独立事务。 */
|
||||
private Future<?> hold(List<Long> ids, AtomicInteger session, CountDownLatch locked, CountDownLatch release) {
|
||||
return workers.submit(() -> transaction.executeWithoutResult(status -> {
|
||||
service.lock(ids);
|
||||
session.set(sessionId());
|
||||
locked.countDown();
|
||||
await(release);
|
||||
}));
|
||||
}
|
||||
|
||||
/** 在各调用方事务实际使用的连接上取得会话身份,不把线程启动标记视为数据库等待证据。 */
|
||||
private int sessionId() {
|
||||
return jdbc.queryForObject("SELECT SESSION_ID()", Integer.class);
|
||||
}
|
||||
|
||||
/** 观察者连接须看到等待者执行串行表 SQL,且实际 BLOCKER_ID 正是持锁事务,才能释放持锁者。 */
|
||||
private void awaitGuardLockWait(int waiter, int holder, Future<?> waiting) throws Exception {
|
||||
assertTrue(waiter > 0 && holder > 0);
|
||||
assertNotEquals(holder, waiter);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
List<Map<String, Object>> lastObserved = List.of();
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (waiting.isDone()) {
|
||||
waiting.get();
|
||||
fail("持锁事务尚未释放,但等待事务未进入数据库锁等待便已完成");
|
||||
}
|
||||
lastObserved = jdbc.queryForList("SELECT SESSION_ID,EXECUTING_STATEMENT,SESSION_STATE,BLOCKER_ID "
|
||||
+ "FROM INFORMATION_SCHEMA.SESSIONS WHERE SESSION_ID=?", waiter);
|
||||
if (lastObserved.size() != 1) continue;
|
||||
var observed = lastObserved.get(0);
|
||||
Object blocker = observed.get("BLOCKER_ID");
|
||||
String sql = String.valueOf(observed.get("EXECUTING_STATEMENT")).toUpperCase(Locale.ROOT);
|
||||
if (blocker instanceof Number number && number.intValue() == holder
|
||||
&& sql.contains("LL_PHYSICAL_PALLET_GUARD") && (sql.contains("INSERT") || sql.contains("UPDATE"))) {
|
||||
assertFalse(waiting.isDone());
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail("未观察到真实实托数据库锁等待,waiter=" + waiter + ",holder=" + holder + ",sessions=" + lastObserved);
|
||||
}
|
||||
|
||||
/** 有界协调并发测试,失败或中断时及时退出以释放事务锁。 */
|
||||
private void await(CountDownLatch latch) {
|
||||
try {
|
||||
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("等待并发测试事务超时");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("并发测试事务已中断", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通连接只读取已提交的真实实托主键。 */
|
||||
private List<Long> ids() {
|
||||
return jdbc.queryForList("SELECT warehouse_pallet_id FROM ll_physical_pallet_guard ORDER BY warehouse_pallet_id", Long.class);
|
||||
}
|
||||
|
||||
/** 核对共同串行行数量,不访问其他业务表或环境。 */
|
||||
private int count() {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard", Integer.class);
|
||||
}
|
||||
|
||||
/** 读取数据库实际保留的首次创建时间。 */
|
||||
private LocalDateTime createdAt(long id) {
|
||||
return jdbc.queryForObject("SELECT created_at FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=?", LocalDateTime.class, id);
|
||||
}
|
||||
|
||||
/** H2 调用的真实插入观测器,仅用于证明去重与数据库执行顺序。 */
|
||||
public static class GuardInsertOrderTrigger implements Trigger {
|
||||
private String observationKey;
|
||||
|
||||
/** 允许 H2 反射创建本测试的数据库触发器。 */
|
||||
public GuardInsertOrderTrigger() { }
|
||||
|
||||
/** 将触发器记录隔离到其真实测试数据库。 */
|
||||
@Override
|
||||
public void init(Connection connection, String schema, String name, String table, boolean before, int type) {
|
||||
observationKey = name;
|
||||
}
|
||||
|
||||
/** 记录数据库实际执行的插入尝试,不记录 Java Mapper 的调用顺序。 */
|
||||
@Override
|
||||
public void fire(Connection connection, Object[] oldRow, Object[] newRow) {
|
||||
INSERT_ORDER.get(observationKey).add(((Number) newRow[0]).longValue());
|
||||
}
|
||||
|
||||
/** 观测器不持有数据库外资源。 */
|
||||
@Override
|
||||
public void close() { }
|
||||
|
||||
/** 用例结束统一清理观测记录,不在删除触发器时改写业务表。 */
|
||||
@Override
|
||||
public void remove() { }
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
package com.baiyue.lastleg.palletops.service;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository;
|
||||
import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService;
|
||||
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
|
||||
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceUtils;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */
|
||||
class PdaPalletDispatchFreezePersistenceTest {
|
||||
private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0);
|
||||
private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L);
|
||||
|
||||
/** 已排申请A与待组托申请B同目的仓,也不能向A的实托追加。 */
|
||||
@Test void dispatchedTargetApplicationBlocksAnotherCustomerBeforePhysicalWrite() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED");
|
||||
f.position(101,"100",501,40,3001,1); f.item(11,1,"100",101L,null,"A-BOX");
|
||||
assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","freeze-target"));
|
||||
assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
}
|
||||
|
||||
/** 普通入仓/退回目标没有换标申请,同样受正式和有效草稿运输占用保护。 */
|
||||
@Test void ordinaryAndReturnedStockClaimsBlockWithoutBorrowingARelabelApplication() throws Exception {
|
||||
for (boolean returned : List.of(false,true)) {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.position(101,"100",501,40,3001,0);
|
||||
if (returned) f.db.update("INSERT INTO ll_return_stock_receipt VALUES(1,'100',3001,101)");
|
||||
else f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1,'100',3001,40,101)");
|
||||
f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)");
|
||||
assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","ordinary-"+returned));
|
||||
assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一UTC时钟只忽略已到期草稿;独立逻辑claim也不能漏检。 */
|
||||
@Test void draftExpiryUsesBusinessClockAndLogicalClaimsAreChecked() throws Exception {
|
||||
for (boolean active : List.of(true,false)) {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,40,'ACTIVE',NULL,?)",active?AT.plusNanos(1_000_000):AT);
|
||||
if (active) assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","draft-active"));
|
||||
else { assertEquals("COMPLETED",f.bind("RL-B","draft-expired").status()); assertEquals("TARGET",f.cartonPallet(802)); }
|
||||
}
|
||||
}
|
||||
|
||||
/** 原生退货完整执行绑定;原始退货订单即使暂扣仍只锁身份,不新增仓内资格。 */
|
||||
@Test void unboundNativeReturnLocksBothActualOrdersAndCompletesBinding() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002");
|
||||
assertEquals("COMPLETED",f.bind("RL-B","native-return").status());
|
||||
assertEquals("TARGET",f.cartonPallet(802));
|
||||
assertEquals(List.of(2002L,3002L),f.orderLocks.get(Thread.currentThread().getName()));
|
||||
assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class));
|
||||
}
|
||||
|
||||
/** 合箱后的每张原始退货单均取真实订单锁,不能只锁sourceReceiptId代表的一票。 */
|
||||
@Test void mergedNativeCartonKeepsEveryOriginalReturnOrder() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.db.update("INSERT INTO ll_order(id,customer_id) VALUES(3003,'200')");
|
||||
f.db.update("INSERT INTO ll_return_order VALUES(3,'200','RT-3',3003)");
|
||||
f.db.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-2,RT-3' WHERE id=802");
|
||||
assertEquals("COMPLETED",f.bind("RL-B","merged-native").status());
|
||||
assertEquals(List.of(2002L,3002L,3003L),f.orderLocks.get(Thread.currentThread().getName()));
|
||||
}
|
||||
|
||||
/** 已绑定计划按真实物理ID取源,排除同码其他实托、异仓及尚未绑定的计划订单。 */
|
||||
@Test void plannedOrdersUsePhysicalBindingAndParentWarehouse() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.planned(101,"100",88,5001,40L,"TARGET");
|
||||
f.planned(102,"300",88,5002,41L,"TARGET");
|
||||
f.planned(103,"400",99,5003,40L,"TARGET");
|
||||
f.planned(104,"500",88,5004,null,"TARGET");
|
||||
assertEquals("COMPLETED",f.bind("RL-B","planned-warehouse").status());
|
||||
assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName()));
|
||||
}
|
||||
|
||||
/** 展示码不同也不能漏掉实际绑定本实托的真实来源订单,身份以最终物理绑定列为准。 */
|
||||
@Test void actualPlanBindingIsNotLostWhenPalletCodeDiffers() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.planned(101,"100",88,5001,40L,"ANOTHER-CODE");
|
||||
assertEquals("COMPLETED",f.bind("RL-B","planned-physical-id").status());
|
||||
assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName()));
|
||||
}
|
||||
|
||||
/** 原始订单身份缺失时明确拒绝,不能仅借申请自身的履约订单通过门禁。 */
|
||||
@Test void missingNativeOriginalOrderCannotBeSilentlyOmitted() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
f.db.update("DELETE FROM ll_return_order WHERE id=2");
|
||||
assertTrue(assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","missing-original")).getReason().contains("原始订单身份"));
|
||||
assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
}
|
||||
|
||||
/** 两个不相交申请都预读空托,第二个等待共同锁后必须发现新成员并整笔拒绝。 */
|
||||
@Test void concurrentEmptyTargetCannotAcceptAStaleComposition() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(1,"RL-A","100",801,3001); f.nativeSource(2,"RL-B","200",802,3002);
|
||||
var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1);
|
||||
f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("second")) secondAtSerial.countDown(); };
|
||||
doAnswer(call -> { if ("empty-B".equals(call.getArgument(4))) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); } return f.transition(call.getArgument(4)); })
|
||||
.when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
var pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first = pool.submit(() -> { Thread.currentThread().setName("first"); return f.bind("RL-B","empty-B"); });
|
||||
assertTrue(firstInside.await(5,TimeUnit.SECONDS));
|
||||
var second = pool.submit(() -> { Thread.currentThread().setName("second"); return assertThrows(ResponseStatusException.class,() -> f.bind("RL-A","empty-A")); });
|
||||
assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown();
|
||||
assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化"));
|
||||
assertEquals("TARGET",f.cartonPallet(802)); assertEquals("UNBOUND",f.cartonPallet(801));
|
||||
} finally { releaseFirst.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
/** 同一闭包从B或A进入均实际按A、B取锁,不出现B→A反序。 */
|
||||
@Test void oppositeSourceApplicationsTakeTheSameRealLockOrder() throws Exception {
|
||||
var f = new Fixture(); f.application(1,"RL-A","100",2001,"WAIT_PRE_DISPATCH"); f.application(2,"RL-B","200",2002,"WAIT_PRE_DISPATCH");
|
||||
f.position(101,"100",501,40,3001,1); f.position(102,"200",502,40,3002,1);
|
||||
f.item(11,1,"100",101L,null,"A-BOX"); f.item(12,2,"200",102L,null,"B-BOX");
|
||||
var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a = pool.submit(() -> { Thread.currentThread().setName("source-A"); start.await(); f.guard("RL-A"); return true; });
|
||||
var b = pool.submit(() -> { Thread.currentThread().setName("source-B"); start.await(); f.guard("RL-B"); return true; });
|
||||
start.countDown(); assertTrue(a.get(5,TimeUnit.SECONDS)); assertTrue(b.get(5,TimeUnit.SECONDS));
|
||||
assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-A"));
|
||||
assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-B"));
|
||||
} finally { pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
/** 箱记录已真实更新后注入失败,业务回滚且远端补偿发生在共同锁仍持有的事务内。 */
|
||||
@Test void laterFailureRollsBackNativeBindingAndCompensatesBeforeUnlock() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var inside = new AtomicBoolean();
|
||||
doAnswer(call -> { assertEquals("TARGET",f.cartonPallet(802)); throw new IllegalStateException("绑定历史写入失败"); })
|
||||
.when(f.histories).insert(any(PdaRelabelHistory.class));
|
||||
doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class)); return null; })
|
||||
.when(f.physical).compensate(eq(88L),any());
|
||||
assertThrows(IllegalStateException.class,() -> f.bind("RL-B","rollback"));
|
||||
assertTrue(inside.get()); assertEquals("UNBOUND",f.cartonPallet(802));
|
||||
assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_relabel_pda_history",Long.class));
|
||||
assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class));
|
||||
assertEquals("FAILED_COMPENSATED",f.operationStatus("rollback"));
|
||||
assertEquals("APPLIED",f.attempts("rollback").get(0).path("receipt").path("operationStatus").asText());
|
||||
verify(f.physical).compensate(eq(88L),any());
|
||||
}
|
||||
|
||||
/** 普通AVAILABLE库存也不能通过assemble追加到已排车的其他客户实托。 */
|
||||
@Test void genericAvailableSourceCannotEnterDispatchedTarget() throws Exception {
|
||||
for (boolean relabelTarget : List.of(true,false)) {
|
||||
var f = new Fixture();
|
||||
if (relabelTarget) f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED");
|
||||
f.position(101,"100",501,40,3001,relabelTarget?1:0);
|
||||
if (relabelTarget) f.item(11,1,"100",101L,null,"A-BOX");
|
||||
f.position(102,"200",502,30,3002,0);
|
||||
f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)");
|
||||
assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-target"));
|
||||
assertEquals(1L,f.available(102));
|
||||
assertEquals(relabelTarget?1L:0L,f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class));
|
||||
assertEquals(relabelTarget?0L:1L,f.available(101));
|
||||
verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 来源实托已占用时普通移动同样被挡住,空申请闭包不代表可以越过运输claim。 */
|
||||
@Test void genericClaimedSourceCannotMoveWithAnEmptyApplicationClosure() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0);
|
||||
f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,30,'ACTIVE',99,NULL)");
|
||||
assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-source"));
|
||||
assertEquals(1L,f.available(102)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
}
|
||||
|
||||
/** 没有关联申请、没有运输claim的普通组托继续执行真实库存和流水,不增加暂扣资格。 */
|
||||
@Test void genericEmptyApplicationClosureCompletesOrdinaryAssemble() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0);
|
||||
f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002");
|
||||
assertEquals("COMPLETED",f.assemble("SOURCE",102,"generic-success").status());
|
||||
assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable());
|
||||
assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
|
||||
assertFalse(f.applicationLocks.containsKey(Thread.currentThread().getName()));
|
||||
assertEquals(List.of(3002L),f.orderLocks.get(Thread.currentThread().getName()));
|
||||
}
|
||||
|
||||
/** 两个空申请闭包竞争空目标,第二个在共同锁后看到新普通位置和订单必须重试。 */
|
||||
@Test void concurrentGenericEmptyTargetRejectsNewOrdinaryMember() throws Exception {
|
||||
var f = new Fixture(); f.position(101,"100",501,30,3001,0); f.position(102,"200",502,50,3002,0);
|
||||
var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1);
|
||||
f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("generic-second")) secondAtSerial.countDown(); };
|
||||
doAnswer(call -> {
|
||||
if (Thread.currentThread().getName().equals("generic-first") && firstInside.getCount()>0) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); }
|
||||
return f.transition(call.getArgument(1),call.getArgument(2),call.getArgument(4));
|
||||
}).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
var pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first = pool.submit(() -> { Thread.currentThread().setName("generic-first"); return f.assemble("SOURCE",101,"generic-empty-A"); });
|
||||
assertTrue(firstInside.await(5,TimeUnit.SECONDS));
|
||||
var second = pool.submit(() -> { Thread.currentThread().setName("generic-second"); return assertThrows(ResponseStatusException.class,() -> f.assemble("OTHER",102,"generic-empty-B")); });
|
||||
assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown();
|
||||
assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化"));
|
||||
assertEquals(0L,f.available(101)); assertEquals(1L,f.available(102)); assertEquals(1L,f.targetAvailable());
|
||||
verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
} finally { releaseFirst.countDown(); pool.shutdownNow(); }
|
||||
}
|
||||
|
||||
/** 普通库存已真实移入目标后失败,两次远端补偿都在共同锁事务内,库存和流水随后回滚。 */
|
||||
@Test void genericFailureCompensatesBeforeUnlockAndRollsBackInventory() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0); var inside = new AtomicBoolean();
|
||||
doAnswer(call -> { assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable()); throw new IllegalStateException("普通组托历史失败"); })
|
||||
.when(f.operationHistory).insert(any(PdaOperationHistory.class));
|
||||
doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class)); return null; })
|
||||
.when(f.physical).compensate(eq(88L),any());
|
||||
assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"generic-rollback"));
|
||||
assertTrue(inside.get()); assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable());
|
||||
assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class));
|
||||
assertEquals("FAILED_COMPENSATED",f.operationStatus("generic-rollback"));
|
||||
assertEquals(2,f.attempts("generic-rollback").size());
|
||||
verify(f.physical,times(2)).compensate(eq(88L),any());
|
||||
}
|
||||
|
||||
/** 首次POST已产生远端效果但响应丢失时,没有已知回执绝不等于全部已补偿。 */
|
||||
@Test void firstApplyCommittedWithoutReceiptRemainsUnresolvedAndOriginalKeyCannotReplay() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap<Long,String>();
|
||||
doAnswer(call -> { PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); effects.put(pallet.palletId(),call.getArgument(2)); throw new IllegalStateException("远端已提交但响应丢失"); })
|
||||
.when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-first"));
|
||||
assertEquals("IDLE",effects.get(30L)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-first"));
|
||||
var attempts=f.attempts("unknown-first"); assertEquals(1,attempts.size()); assertTrue(attempts.get(0).path("receipt").isNull());
|
||||
assertEquals(30L,attempts.get(0).path("palletId").asLong()); assertFalse(attempts.get(0).path("operationKey").asText().isBlank());
|
||||
assertEquals(1L,f.available(102)); assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-first"));
|
||||
verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any());
|
||||
}
|
||||
|
||||
/** 已知第一项补偿成功,不能抹去第二次已提交但未回执的实托调用。 */
|
||||
@Test void laterUnknownApplyCannotBeClassifiedCompensatedByRestoringEarlierReceipt() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap<Long,String>(); var calls = new AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); String status=call.getArgument(2); effects.put(pallet.palletId(),status);
|
||||
if (calls.incrementAndGet()==2) throw new IllegalStateException("第二个实托已提交但回执解析失败");
|
||||
return f.transition(pallet,status,call.getArgument(4));
|
||||
}).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
doAnswer(call -> { assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); PalletPhysicalGateway.Transition receipt=call.getArgument(1); effects.put(receipt.palletId(),receipt.beforeStatus()); return null; })
|
||||
.when(f.physical).compensate(anyLong(),any());
|
||||
assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-later"));
|
||||
assertEquals("OCCUPIED",effects.get(30L)); assertEquals("OCCUPIED",effects.get(40L));
|
||||
assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-later"));
|
||||
var attempts=f.attempts("unknown-later"); assertEquals(2,attempts.size());
|
||||
assertEquals("APPLIED",attempts.get(0).path("receipt").path("operationStatus").asText()); assertTrue(attempts.get(1).path("receipt").isNull());
|
||||
assertEquals(40L,attempts.get(1).path("palletId").asLong()); assertEquals(1L,f.available(102));
|
||||
assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-later"));
|
||||
verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,times(1)).compensate(eq(88L),any());
|
||||
}
|
||||
|
||||
/** 原生无来源托盘分支同样持久保留已发未确认事实,原箱指针回滚且原键不再发写。 */
|
||||
@Test void nativeReturnUnknownApplyPersistsPendingAttemptAndBlocksOriginalKey() throws Exception {
|
||||
var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var committed = new AtomicBoolean();
|
||||
doAnswer(call -> { committed.set(true); throw new IllegalStateException("退货实托已提交但响应丢失"); })
|
||||
.when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
assertThrows(IllegalStateException.class,() -> f.bind("RL-B","native-unknown"));
|
||||
assertTrue(committed.get()); assertEquals("UNBOUND",f.cartonPallet(802)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("native-unknown"));
|
||||
var attempt=f.attempts("native-unknown").get(0); assertEquals("native-unknown",attempt.path("operationKey").asText()); assertTrue(attempt.path("receipt").isNull());
|
||||
assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","native-unknown"));
|
||||
verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any());
|
||||
}
|
||||
|
||||
/** 空、身份不符以及已补偿的旧回执都不能冒充本次有效APPLIED成功。 */
|
||||
@Test void invalidOrCompensatedReceiptNeverAdvancesAndRemainsPending() throws Exception {
|
||||
for (String kind : List.of("NULL","OTHER_KEY","COMPENSATED")) {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0);
|
||||
doAnswer(call -> {
|
||||
if (kind.equals("NULL")) return null;
|
||||
PalletPhysicalGateway.PalletReference pallet=call.getArgument(1);
|
||||
return new PalletPhysicalGateway.Transition(kind.equals("OTHER_KEY")?"different-key":call.getArgument(4),pallet.palletId(),pallet.palletCode(),88,
|
||||
pallet.status(),call.getArgument(2),pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,kind.equals("COMPENSATED")?"COMPENSATED":"APPLIED",true);
|
||||
}).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"invalid-"+kind));
|
||||
assertEquals("COMPENSATION_REQUIRED",f.operationStatus("invalid-"+kind)); assertTrue(f.attempts("invalid-"+kind).get(0).path("receipt").isNull());
|
||||
assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable()); verify(f.physical,never()).compensate(anyLong(),any());
|
||||
}
|
||||
}
|
||||
|
||||
/** Warehouse的APPLIED精确重放仍是有效回执,不能凭replayed标记拒绝合法成功。 */
|
||||
@Test void matchingAppliedReplayReceiptRemainsValid() throws Exception {
|
||||
var f = new Fixture(); f.position(102,"200",502,30,3002,0);
|
||||
doAnswer(call -> {
|
||||
PalletPhysicalGateway.PalletReference pallet=call.getArgument(1);
|
||||
return new PalletPhysicalGateway.Transition(call.getArgument(4),pallet.palletId(),pallet.palletCode(),88,pallet.status(),call.getArgument(2),
|
||||
pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",true);
|
||||
}).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString());
|
||||
assertEquals("COMPLETED",f.assemble("SOURCE",102,"applied-replay").status());
|
||||
assertEquals("COMPLETED",f.operationStatus("applied-replay")); assertEquals(1L,f.targetAvailable());
|
||||
assertTrue(f.attempts("applied-replay").get(0).path("receipt").path("replayed").asBoolean());
|
||||
}
|
||||
|
||||
/** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */
|
||||
static class Fixture {
|
||||
final JdbcTemplate db;
|
||||
final PdaPalletOperationService service = new PdaPalletOperationService();
|
||||
final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class);
|
||||
final PdaRelabelHistoryMapper histories;
|
||||
final PdaWarehouseMapper operationHistory;
|
||||
final RelabelApplicationMapper applications;
|
||||
final TransactionTemplate tx;
|
||||
final String baseline;
|
||||
final Map<String,List<String>> applicationLocks = new ConcurrentHashMap<>();
|
||||
final Map<String,List<Long>> orderLocks = new ConcurrentHashMap<>();
|
||||
volatile Consumer<List<Long>> serialObserver = ids -> { };
|
||||
|
||||
/** 构建实际数据映射与Spring事务;测试表只服务本次隔离用例。 */
|
||||
Fixture() throws Exception {
|
||||
// 底层连接故意默认RR;完整作业入口必须显式切到RC,不能借H2默认RC通过。
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","") {
|
||||
/** 每条新连接模拟目标MySQL默认RR。 */
|
||||
@Override public java.sql.Connection getConnection() throws java.sql.SQLException {
|
||||
var connection = super.getConnection(); connection.setTransactionIsolation(java.sql.Connection.TRANSACTION_REPEATABLE_READ); return connection;
|
||||
}
|
||||
};
|
||||
db = new JdbcTemplate(ds);
|
||||
baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type);
|
||||
createColumns("ll_order",List.of("id","customer_id","detained"));
|
||||
createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id"));
|
||||
createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id"));
|
||||
createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id"));
|
||||
createColumns("ll_inbound_plan_pallet",List.of("id","customer_id","plan_id","pallet_no","warehouse_pallet_id"));
|
||||
createColumns("ll_inbound_plan_pallet_allocation",List.of("pallet_id","line_id","customer_id","plan_id"));
|
||||
createColumns("ll_inbound_plan_line",List.of("id","customer_id","order_id","plan_id"));
|
||||
createColumns("ll_return_order",List.of("id","customer_id","return_no","lastleg_order_id"));
|
||||
createColumns("ll_dispatch_physical_pallet_lock",List.of("id","warehouse_pallet_id","active_warehouse_pallet_id","status","dispatch_order_id","expires_at"));
|
||||
createColumns("ll_dispatch_pallet_lock",List.of("id","warehouse_pallet_id","status","dispatch_order_id","expires_at"));
|
||||
createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at"));
|
||||
createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status"));
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
|
||||
var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
|
||||
PdaPalletOperationMapper actual = sessions.getMapper(PdaPalletOperationMapper.class);
|
||||
PdaPalletOperationMapper traced = (PdaPalletOperationMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class<?>[]{PdaPalletOperationMapper.class},(proxy,method,args) -> {
|
||||
if (method.getName().equals("lockMutationApplication")) applicationLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((String)args[0]);
|
||||
if (method.getName().equals("lockMutationOrder")) orderLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((Long)args[1]);
|
||||
try { return method.invoke(actual,args); } catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); }
|
||||
});
|
||||
var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
var serial = new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(sessions.getMapper(PhysicalPalletSerialMapper.class)));
|
||||
PhysicalPalletSerialPort observedSerial = ids -> {
|
||||
try { assertEquals(java.sql.Connection.TRANSACTION_READ_COMMITTED,DataSourceUtils.getConnection(ds).getTransactionIsolation()); }
|
||||
catch (java.sql.SQLException failure) { throw new IllegalStateException(failure); }
|
||||
serialObserver.accept(ids); serial.lock(ids);
|
||||
};
|
||||
applications = sessions.getMapper(RelabelApplicationMapper.class); histories = spy(sessions.getMapper(PdaRelabelHistoryMapper.class));
|
||||
operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class));
|
||||
var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory);
|
||||
ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced));
|
||||
ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical);
|
||||
ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class));
|
||||
ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class));
|
||||
ReflectionTestUtils.setField(service,"positions",sessions.getMapper(InventoryPositionMapper.class)); ReflectionTestUtils.setField(service,"balances",sessions.getMapper(InventoryBalanceMapper.class));
|
||||
ReflectionTestUtils.setField(service,"ledgers",sessions.getMapper(InventoryLedgerMapper.class)); ReflectionTestUtils.setField(service,"inventoryCommands",sessions.getMapper(InventoryCommandMapper.class));
|
||||
ReflectionTestUtils.setField(service,"history",historyService);
|
||||
ReflectionTestUtils.setField(service,"relabelWorkTasks",sessions.getMapper(PdaRelabelTaskMapper.class)); ReflectionTestUtils.setField(service,"relabelWorkHistory",histories);
|
||||
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service,"mutationClock",Clock.fixed(AT.toInstant(ZoneOffset.UTC),ZoneOffset.UTC));
|
||||
service.configureTransactions(manager);
|
||||
when(physical.resolve("TARGET")).thenReturn(target());
|
||||
when(physical.resolve("SOURCE")).thenReturn(new PalletPhysicalGateway.PalletReference(30,"SOURCE",88,null,"OCCUPIED","SCK8",0));
|
||||
when(physical.resolve("OTHER")).thenReturn(new PalletPhysicalGateway.PalletReference(50,"OTHER",88,null,"OCCUPIED","SCK8",0));
|
||||
when(physical.apply(anyLong(),any(),anyString(),any(),anyString(),anyString())).thenAnswer(call -> transition(call.getArgument(1),call.getArgument(2),call.getArgument(4)));
|
||||
}
|
||||
|
||||
/** 实体仅决定需映射的列集合,列名和类型必须实际存在于当前统一DDL。 */
|
||||
private void createEntity(Class<?> type) {
|
||||
var columns = new ArrayList<String>();
|
||||
for (var field : type.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue;
|
||||
var mapping = field.getAnnotation(TableField.class); if (mapping != null && !mapping.exist()) continue;
|
||||
String name = mapping != null && !mapping.value().isBlank() ? mapping.value() : field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT);
|
||||
columns.add(name);
|
||||
}
|
||||
createColumns(type.getAnnotation(TableName.class).value(),columns);
|
||||
}
|
||||
|
||||
/** 从真实DDL提取所需普通列,支持同一行多列;隔离表只省略与本专项无关的约束。 */
|
||||
private void createColumns(String table,List<String> requested) {
|
||||
var block = Pattern.compile("(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?`?"+Pattern.quote(table)+"`?\\s*\\((.*?)\\)\\s*ENGINE").matcher(baseline);
|
||||
assertTrue(block.find(),"当前基线缺表:"+table);
|
||||
String body = block.group(1).replaceAll("'(?:''|[^'])*'"," ");
|
||||
var columns = new LinkedHashMap<String,String>();
|
||||
var matcher = Pattern.compile("(?i)(?:\\A|,)\\s*`?([a-z_][a-z0-9_]*)`?\\s+(BIGINT|TINYINT|SMALLINT|INTEGER|INT|VARCHAR|CHAR|DECIMAL|NUMERIC|DATETIME|TIMESTAMP|DATE|LONGTEXT|TEXT|JSON|BOOLEAN|DOUBLE|FLOAT|BIT)(\\s*\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?").matcher(body);
|
||||
while (matcher.find()) {
|
||||
String type = matcher.group(2).toUpperCase(Locale.ROOT)+Objects.toString(matcher.group(3),"");
|
||||
// H2隔离表保留JSON列名,以文本承载既有Jackson字符串,不验证MySQL JSON存储行为。
|
||||
if (type.equals("JSON")) type="LONGTEXT";
|
||||
columns.put(matcher.group(1).toLowerCase(Locale.ROOT),type);
|
||||
}
|
||||
var definitions = new ArrayList<String>();
|
||||
for (String column : requested) {
|
||||
assertTrue(columns.containsKey(column),"测试列不存在于当前真实DDL:"+table+"."+column);
|
||||
String definition = column+" "+columns.get(column);
|
||||
if (column.equals("id") || table.equals("ll_physical_pallet_guard") && column.equals("warehouse_pallet_id")) definition += " PRIMARY KEY";
|
||||
if (column.equals("created_at") && table.equals("ll_physical_pallet_guard")) definition += " DEFAULT CURRENT_TIMESTAMP(3) NOT NULL";
|
||||
if (column.equals("detained") && table.equals("ll_order")) definition += " DEFAULT 0";
|
||||
definitions.add(definition);
|
||||
}
|
||||
db.execute("CREATE TABLE "+table+" ("+String.join(",",definitions)+")");
|
||||
}
|
||||
|
||||
/** 建立真实申请及其独立履约订单身份。 */
|
||||
void application(long id,String no,String customer,long order,String fulfillment) {
|
||||
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer);
|
||||
db.update("INSERT INTO ll_relabel_application(id,relabel_no,customer_id,overseas_warehouse_id,lastleg_order_id,status,fulfillment_status) VALUES(?,?,?,'88',?,'READY_FOR_OUTBOUND',?)",id,no,customer,order,fulfillment);
|
||||
}
|
||||
|
||||
/** 构造原始退货订单、实收箱和无来源托盘的换标明细。 */
|
||||
void nativeSource(long id,String no,String customer,long carton,long originalOrder) {
|
||||
application(id,no,customer,2000+id,"WAIT_PRE_DISPATCH");
|
||||
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",originalOrder,customer);
|
||||
db.update("INSERT INTO ll_return_order VALUES(?,?,?,?)",id,customer,"RT-"+id,originalOrder);
|
||||
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_receipt_id,source_return_no,pallet_code,carton_mark,relabel_application_id,status,total_quantity,destination_warehouse_code) VALUES(?,?,'88',?,?, 'UNBOUND',?,?,'RELABEL_PENDING',2,'SCK8')",carton,customer,500+id,"RT-"+id,"BOX-"+id,id);
|
||||
item(10+id,id,customer,null,carton,"BOX-"+id);
|
||||
db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,status) VALUES(?,?,?,'COMPLETED')",900+id,id,customer);
|
||||
}
|
||||
|
||||
/** 使用最终基线物理绑定列准备计划,分配链同时保留plan_id和customer_id。 */
|
||||
void planned(long plan,String customer,long warehouse,long order,Long physicalPalletId,String palletCode) {
|
||||
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer);
|
||||
db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id) VALUES(?,?,?)",plan,customer,warehouse);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(?,?,?,?,?)",plan+1000,customer,plan,palletCode,physicalPalletId);
|
||||
db.update("INSERT INTO ll_inbound_plan_line(id,customer_id,order_id,plan_id) VALUES(?,?,?,?)",plan+2000,customer,order,plan);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation(pallet_id,line_id,customer_id,plan_id) VALUES(?,?,?,?)",plan+1000,plan+2000,customer,plan);
|
||||
}
|
||||
|
||||
/** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */
|
||||
void position(long id,String customer,long balance,long pallet,long order,long held) {
|
||||
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer);
|
||||
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order);
|
||||
String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER";
|
||||
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0);
|
||||
}
|
||||
|
||||
/** 明细原始订单从真实余额身份取得,原生退货保持其独立箱身份。 */
|
||||
void item(long id,long app,String customer,Long position,Long carton,String mark) {
|
||||
Long order = position == null ? null : db.queryForObject("SELECT b.inbound_order_id FROM ll_inventory_balance b JOIN ll_inventory_position p ON p.balance_id=b.id WHERE p.id=?",Long.class,position);
|
||||
db.update("INSERT INTO ll_relabel_item(id,application_id,customer_id,source_inventory_position_id,return_carton_id,carton_mark,notified_package_count,source_inbound_order_id,destination_fba_warehouse_code) VALUES(?,?,?,?,?,?,1,?,'SCK8')",id,app,customer,position,carton,mark,order);
|
||||
}
|
||||
|
||||
/** 执行现有原生退货入口,不绕开三个分支共用的生产门禁。 */
|
||||
com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation bind(String no,String key) {
|
||||
var app = applications.findAnyByRelabelNo(no);
|
||||
String mark = db.queryForObject("SELECT carton_mark FROM ll_relabel_item WHERE application_id=?",String.class,app.getId());
|
||||
return service.relabelDisassemble(ACTOR,new PalletOperationRequests.RelabelDisassemble(key,no,null,new PalletOperationRequests.PalletExpectation("TARGET",0),List.of(mark),"TEST合成资料"));
|
||||
}
|
||||
|
||||
/** 在真实事务内单独观察生产门禁的全局锁序。 */
|
||||
void guard(String no) { tx.executeWithoutResult(status -> ReflectionTestUtils.invokeMethod(service,"lockMutationComposition",ACTOR,applications.findAnyByRelabelNo(no),Map.of("TARGET",target()))); }
|
||||
|
||||
/** 普通组托通过公开既有服务入口执行,不伪造换标申请。 */
|
||||
com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation assemble(String source,long position,String key) {
|
||||
return service.assemble(ACTOR,new PalletOperationRequests.Assemble(key,new PalletOperationRequests.PalletExpectation("TARGET",0),
|
||||
List.of(new PalletOperationRequests.PalletExpectation(source,0)),List.of(new PalletOperationRequests.MoveLine(position,1,0)),"TEST合成资料"));
|
||||
}
|
||||
|
||||
/** 读取真实位置当前可用量。 */
|
||||
long available(long id) { return Objects.requireNonNull(db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=?",Long.class,id)); }
|
||||
|
||||
/** 目标托全部客户贡献合计只用于测试核对。 */
|
||||
long targetAvailable() { return Objects.requireNonNull(db.queryForObject("SELECT COALESCE(SUM(available_packages),0) FROM ll_inventory_position WHERE pallet_id=40",Long.class)); }
|
||||
|
||||
/** 读取真实独立意图行的最终分类,不从抛出的异常或Mock次数推定恢复成功。 */
|
||||
String operationStatus(String key) { return db.queryForObject("SELECT status FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key); }
|
||||
|
||||
/** 解析实际落盘的唯一attempt结构,确认未回执事实没有随业务回滚消失。 */
|
||||
com.fasterxml.jackson.databind.JsonNode attempts(String key) throws Exception {
|
||||
return new ObjectMapper().readTree(db.queryForObject("SELECT remote_transitions_json FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key));
|
||||
}
|
||||
|
||||
/** 读取提交后的箱绑定,亦可在当前事务内验证回滚注入点。 */
|
||||
String cartonPallet(long id) { return db.queryForObject("SELECT pallet_code FROM ll_return_inventory_carton WHERE id=?",String.class,id); }
|
||||
|
||||
/** 目标来自受控的真实资源接口回执,未伪造来源仓位。 */
|
||||
PalletPhysicalGateway.PalletReference target() { return new PalletPhysicalGateway.PalletReference(40,"TARGET",88,null,"OCCUPIED","SCK8",0); }
|
||||
|
||||
/** 仅模拟跨服务响应,库存、明细、历史及本地锁仍走实际Mapper。 */
|
||||
PalletPhysicalGateway.Transition transition(String key) { return new PalletPhysicalGateway.Transition(key,40,"TARGET",88,"OCCUPIED","OCCUPIED",null,null,0,1,"APPLIED",false); }
|
||||
|
||||
/** 按本次实际调用实托返回受控远端回执,不把目标托回执借给来源托。 */
|
||||
PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,String status,String key) {
|
||||
return new PalletPhysicalGateway.Transition(key,pallet.palletId(),pallet.palletCode(),pallet.warehouseId(),pallet.status(),status,pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-6
@@ -10,6 +10,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
|
||||
import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort;
|
||||
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
@@ -59,6 +61,8 @@ class PdaPalletOperationServiceTest {
|
||||
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
|
||||
}
|
||||
@Mock private PdaPalletOperationMapper mapper;
|
||||
@Mock private PdaPalletOperationRepository mutationRepository;
|
||||
@Mock private PhysicalPalletSerialPort physicalPalletSerial;
|
||||
@Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
|
||||
@Mock private InventoryPositionMapper positions;
|
||||
@Mock private InventoryBalanceMapper balances;
|
||||
@@ -77,6 +81,19 @@ class PdaPalletOperationServiceTest {
|
||||
service = new PdaPalletOperationService();
|
||||
ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class));
|
||||
ReflectionTestUtils.setField(service, "mapper", mapper);
|
||||
ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository);
|
||||
ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial);
|
||||
// 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。
|
||||
lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> {
|
||||
java.util.Map<Long,String> pallets = call.getArgument(1);
|
||||
RelabelApplication application = call.getArgument(2);
|
||||
return new PdaPalletOperationRepository.Composition(application == null ? List.of() : List.of(application),List.of(),pallets.entrySet().stream()
|
||||
.map(entry -> new PdaPalletOperationMapper.MutationPallet(entry.getKey(),entry.getValue())).toList(),List.of());
|
||||
});
|
||||
lenient().when(mutationRepository.lockApplication(anyString())).thenReturn(true);
|
||||
lenient().when(relabelItems.selectById(anyLong())).thenAnswer(call -> relabelItems.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()).stream()
|
||||
.filter(item -> java.util.Objects.equals(item.getId(),call.getArgument(0))).findFirst().orElse(null));
|
||||
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "balances", balances);
|
||||
@@ -118,9 +135,12 @@ class PdaPalletOperationServiceTest {
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", workTasks);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkHistory", workHistory);
|
||||
var app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
|
||||
app.setRelabelNo("RL-1"); app.setStatus("READY_FOR_OUTBOUND"); app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); app.setOverseasWarehouseId("88");
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(700L);
|
||||
when(workTasks.find("100", 600L)).thenReturn(task);
|
||||
var item = new RelabelItem(); item.setReturnCartonId(801L); item.setCartonMark("BOX-1");
|
||||
item.setId(701L); item.setApplicationId(600L); item.setCustomerId("100");
|
||||
doReturn(item).when(relabelItems).selectById(701L);
|
||||
item.setDestinationFbaWarehouseCode("SCK8"); item.setNotifiedPackageCount(1);
|
||||
var box = new ReturnInventoryCarton(); box.setId(801L); box.setCustomerId("100");
|
||||
box.setPalletCode("SOURCE"); box.setRelabelApplicationId(600L); box.setStatus("RELABEL_PENDING");
|
||||
@@ -133,6 +153,7 @@ class PdaPalletOperationServiceTest {
|
||||
var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", "SCK8", 1);
|
||||
var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, "OCCUPIED", "SCK8", 2);
|
||||
when(returnCartons.lockId(801L)).thenReturn(box);
|
||||
when(returnCartons.selectById(801L)).thenReturn(box);
|
||||
when(returnCartons.palletCartons("88", "TARGET")).thenAnswer(call ->
|
||||
"TARGET".equals(box.getPalletCode()) ? List.of(existing, box) : List.of(existing));
|
||||
var request = new PalletOperationRequests.RelabelDisassemble("rebind-current", "RL-1",
|
||||
@@ -154,6 +175,11 @@ class PdaPalletOperationServiceTest {
|
||||
new PalletPhysicalGateway.LocationReference(1010L, "TARGET-LOC", "STORAGE", "ACTIVE"));
|
||||
when(physical.resolve("SOURCE")).thenReturn(source);
|
||||
when(physical.resolve("TARGET")).thenReturn(target);
|
||||
AtomicReference<PdaPalletOperation> operation = new AtomicReference<>();
|
||||
doAnswer(call -> { operation.set(call.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class));
|
||||
when(mapper.selectById(anyLong())).thenAnswer(call -> operation.get());
|
||||
when(physical.apply(eq(88L),eq(target),eq("OCCUPIED"),eq(1010L),eq("rebind-current"),anyString()))
|
||||
.thenReturn(transition(target,"OCCUPIED","rebind-current"));
|
||||
var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation)
|
||||
ReflectionTestUtils.invokeMethod(service, "bindReturnRelabelPallet", identity(), app,
|
||||
List.of(item), source, target, request, false);
|
||||
@@ -324,7 +350,7 @@ class PdaPalletOperationServiceTest {
|
||||
.when(mapper).insert(any(PdaPalletOperation.class));
|
||||
when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get());
|
||||
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4)));
|
||||
|
||||
var request = new PalletOperationRequests.Assemble("idem-1",
|
||||
new PalletOperationRequests.PalletExpectation("TARGET", 2),
|
||||
@@ -399,7 +425,7 @@ class PdaPalletOperationServiceTest {
|
||||
when(physical.resolve("SOURCE")).thenReturn(sourcePallet);
|
||||
when(physical.resolve("TARGET")).thenReturn(targetPallet);
|
||||
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4)));
|
||||
|
||||
RelabelApplication application = new RelabelApplication(); application.setId(600L);
|
||||
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
@@ -489,7 +515,7 @@ class PdaPalletOperationServiceTest {
|
||||
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
RelabelItem item = relabelItem(701L, "CARTON-A", 101L);
|
||||
when(relabelItems.selectList(any())).thenReturn(List.of(item));
|
||||
when(relabelItems.selectById(701L)).thenReturn(item);
|
||||
doReturn(item).when(relabelItems).selectById(701L);
|
||||
InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L);
|
||||
InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L);
|
||||
AtomicReference<InventoryPosition> target = new AtomicReference<>();
|
||||
@@ -506,7 +532,7 @@ class PdaPalletOperationServiceTest {
|
||||
var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2);
|
||||
when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst);
|
||||
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4)));
|
||||
when(mapper.content("88", 10L)).thenReturn(List.of());
|
||||
when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages());
|
||||
when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages());
|
||||
@@ -620,8 +646,8 @@ class PdaPalletOperationServiceTest {
|
||||
id == 20L ? "IDLE" : "OCCUPIED", null, version);
|
||||
}
|
||||
private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,
|
||||
String targetStatus) {
|
||||
return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(),
|
||||
String targetStatus,String key) {
|
||||
return new PalletPhysicalGateway.Transition(key, pallet.palletId(), pallet.palletCode(),
|
||||
pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(),
|
||||
pallet.version(), pallet.version() + 1, "APPLIED", false);
|
||||
}
|
||||
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelDispatchSourceMapper;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot;
|
||||
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 隔离 H2 中执行真实 Mapper,验证闭包、箱数、轮次和调用方事务锁。 */
|
||||
class RelabelDispatchSourcePersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private RelabelDispatchSourceService service;
|
||||
private DataSourceTransactionManager transactionManager;
|
||||
private TransactionTemplate transaction;
|
||||
private final List<String> lockCalls = new ArrayList<>();
|
||||
|
||||
/** 创建最小业务表并装配实际 MyBatis Mapper 与事务管理器。 */
|
||||
@BeforeEach
|
||||
void prepare() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_relabel_application (
|
||||
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), relabel_no VARCHAR(60) UNIQUE,
|
||||
lastleg_order_id BIGINT, overseas_warehouse_id VARCHAR(40), delivery_method VARCHAR(40),
|
||||
status VARCHAR(40), fulfillment_status VARCHAR(40), updated_at TIMESTAMP,
|
||||
customer_confirmed_at TIMESTAMP)
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_relabel_item (
|
||||
id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40),
|
||||
notified_package_count INT, total_package_count INT, carton_mark VARCHAR(80),
|
||||
operation_type VARCHAR(40), sku_relabel_items_json VARCHAR(4000), return_carton_id BIGINT,
|
||||
source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT,
|
||||
source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_inventory_position (
|
||||
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
|
||||
overseas_warehouse_id VARCHAR(40), pallet_id BIGINT, pallet_no VARCHAR(80),
|
||||
available_packages BIGINT, hold_packages BIGINT, allocated_packages BIGINT,
|
||||
picked_packages BIGINT, receiving_packages BIGINT, version BIGINT)
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_relabel_pda_task (
|
||||
id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40),
|
||||
relabel_no VARCHAR(60), overseas_warehouse_id BIGINT, status VARCHAR(40),
|
||||
reviewed_at TIMESTAMP, version INT)
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_relabel_pda_history (
|
||||
id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, customer_id VARCHAR(40),
|
||||
action VARCHAR(40), detail_json VARCHAR(4000), created_at TIMESTAMP)
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_relabel_pda_unit_fact (
|
||||
id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, item_id BIGINT,
|
||||
customer_id VARCHAR(40), old_label VARCHAR(80), new_label VARCHAR(80),
|
||||
result VARCHAR(40), quantity INT, occurred_at TIMESTAMP)
|
||||
""");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(RelabelDispatchSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
RelabelDispatchSourceMapper actual = new SqlSessionTemplate(factory.getObject())
|
||||
.getMapper(RelabelDispatchSourceMapper.class);
|
||||
// 只记录真实 Mapper 的锁调用次序,所有查询及 FOR UPDATE 仍交给实际数据库执行。
|
||||
RelabelDispatchSourceMapper observed = (RelabelDispatchSourceMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(), new Class<?>[]{RelabelDispatchSourceMapper.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("lockApplication")) lockCalls.add((String) args[0]);
|
||||
try { return method.invoke(actual, args); }
|
||||
catch (InvocationTargetException failure) { throw failure.getCause(); }
|
||||
});
|
||||
service = new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(observed), new ObjectMapper());
|
||||
transactionManager = new DataSourceTransactionManager(dataSource);
|
||||
transaction = new TransactionTemplate(transactionManager);
|
||||
transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
position(101, 1001, "PALLET-1", 10);
|
||||
application(1, "RL-B", 9001);
|
||||
application(2, "RL-A", 9002);
|
||||
item(11, 1, 101, "BOX-1", 3, 501, 7001);
|
||||
item(21, 2, 101, "BOX-2", 4, 502, 7002);
|
||||
binding(10, 1, "BOX-1", "PALLET-1", "INITIAL");
|
||||
binding(20, 2, "BOX-2", "PALLET-1", "INITIAL");
|
||||
fact(11, 1, 11, "RELABELLED");
|
||||
fact(12, 1, 11, "EXCEPTION");
|
||||
fact(21, 2, 21, "RELABELLED");
|
||||
}
|
||||
|
||||
/** 两个申请共用同一位置时合计绑定数,不让每张申请单独通过 HOLD 校验。 */
|
||||
@Test
|
||||
void combinedContributionsCannotExceedActualHold() {
|
||||
jdbc.update("UPDATE ll_inventory_position SET hold_packages=6 WHERE id=101");
|
||||
Snapshot snapshot = snapshot();
|
||||
assertEquals(2, snapshot.applications().size());
|
||||
assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum());
|
||||
assertEquals(6, snapshot.positions().get(0).holdPackages());
|
||||
assertFalse(snapshot.complete());
|
||||
assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("HOLD")));
|
||||
}
|
||||
|
||||
/** 逐明细冻结原入仓链保留独立身份,履约订单及当前余额不能替代原货源。 */
|
||||
@Test
|
||||
void sourceProvenanceComesFromEachItem() {
|
||||
Snapshot snapshot = snapshot();
|
||||
assertTrue(snapshot.complete(), snapshot.blockReasons().toString());
|
||||
var first = snapshot.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow();
|
||||
var second = snapshot.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow();
|
||||
assertEquals(7001L, first.sourceInboundOrderId());
|
||||
assertEquals("IN-7001", first.sourceInboundOrderNo());
|
||||
assertEquals("CODE-7001", first.sourceInboundCode());
|
||||
assertEquals(501L, first.sourceInventoryBalanceId());
|
||||
assertEquals(7002L, second.sourceInboundOrderId());
|
||||
assertEquals(502L, second.sourceInventoryBalanceId());
|
||||
assertEquals(600L, first.inventoryBalanceId());
|
||||
assertEquals(9001L, snapshot.applications().stream().filter(a -> a.applicationId() == 1)
|
||||
.findFirst().orElseThrow().fulfillmentOrderId());
|
||||
}
|
||||
|
||||
/** 绑定、通知、成功及异常各自保留,历史数量不放大箱数,纯箱标件数未知。 */
|
||||
@Test
|
||||
void workFactsAreGroupedBeforeCombiningAndCartonPiecesStayUnknown() {
|
||||
binding(22, 2, "BOX-2", "PALLET-1", "INITIAL");
|
||||
Snapshot snapshot = snapshot();
|
||||
assertTrue(snapshot.complete(), snapshot.blockReasons().toString());
|
||||
assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::notifiedPackageCount).sum());
|
||||
assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum());
|
||||
var facts = snapshot.sources().stream().flatMap(source -> source.unitFacts().stream()).toList();
|
||||
assertEquals(3, facts.size());
|
||||
assertEquals(2, facts.stream().filter(f -> f.result().equals("RELABELLED")).count());
|
||||
assertEquals(1, facts.stream().filter(f -> f.result().equals("EXCEPTION")).count());
|
||||
assertTrue(snapshot.sources().stream().allMatch(source -> source.productQuantity() == null));
|
||||
}
|
||||
|
||||
/** 商品件数只来自当前轮次完整的实际 SKU 扫描,不使用逐箱事实 quantity=1 推算。 */
|
||||
@Test
|
||||
void skuPiecesRequireCurrentCompleteScanFacts() {
|
||||
jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL', sku_relabel_items_json=? WHERE id=11",
|
||||
"[{\"quantity\":5},{\"quantity\":7}]");
|
||||
skuScan(30, 0, 5, "INITIAL");
|
||||
assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity());
|
||||
skuScan(31, 1, 7, "INITIAL");
|
||||
assertEquals(12L, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity());
|
||||
jdbc.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE id=31",
|
||||
"{\"round\":\"INITIAL\",\"itemId\":\"11\",\"skuIndex\":1,\"quantity\":99}");
|
||||
assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity());
|
||||
}
|
||||
|
||||
/** 初轮 PDA 完工后客户确认改写复核时间,仍须保留 INITIAL 绑定与实际 SKU 件数。 */
|
||||
@Test
|
||||
void customerConfirmationMustNotReplaceInitialWorkRound() {
|
||||
jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1");
|
||||
jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11",
|
||||
"[{\"quantity\":5},{\"quantity\":7}]");
|
||||
skuScan(30, 0, 5, "INITIAL");
|
||||
skuScan(31, 1, 7, "INITIAL");
|
||||
complete(50, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0));
|
||||
Snapshot before = snapshot();
|
||||
assertTrue(before.complete(), before.blockReasons().toString());
|
||||
assertEquals("INITIAL", before.applications().stream().filter(a -> a.applicationId() == 1)
|
||||
.findFirst().orElseThrow().roundKey());
|
||||
var beforeSource = before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow();
|
||||
assertEquals(3, beforeSource.boundPackageCount());
|
||||
assertEquals(12L, beforeSource.productQuantity());
|
||||
|
||||
LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 5, 10, 30);
|
||||
customerConfirm(1, confirmedAt);
|
||||
Snapshot after = snapshot();
|
||||
assertTrue(after.complete(), after.blockReasons().toString());
|
||||
var application = after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow();
|
||||
var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow();
|
||||
assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class));
|
||||
assertEquals("INITIAL", application.roundKey());
|
||||
assertEquals(confirmedAt, application.customerConfirmedAt());
|
||||
assertEquals(3, source.boundPackageCount());
|
||||
assertEquals(12L, source.productQuantity());
|
||||
assertEquals(beforeSource.unitFacts(), source.unitFacts());
|
||||
assertNotEquals(before.compositionVersion(), after.compositionVersion());
|
||||
}
|
||||
|
||||
/** 返工新轮次取实际绑定及完工历史,客户再次确认的时间不能替换返工作业轮次。 */
|
||||
@Test
|
||||
void customerConfirmationPreservesReworkRoundAndRejectsPriorScans() {
|
||||
jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11",
|
||||
"[{\"quantity\":5},{\"quantity\":7}]");
|
||||
skuScan(30, 0, 5, "INITIAL");
|
||||
skuScan(31, 1, 7, "INITIAL");
|
||||
complete(35, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0));
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')");
|
||||
LocalDateTime reworkAt = LocalDateTime.of(2026, 1, 5, 0, 0, 1);
|
||||
String round = reworkAt.toString();
|
||||
jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at=?,version=2 WHERE application_id=1", reworkAt);
|
||||
jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1");
|
||||
jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1");
|
||||
binding(41, 1, "BOX-1", "PALLET-1", round);
|
||||
jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41");
|
||||
fact(42, 1, 11, "RELABELLED");
|
||||
jdbc.update("UPDATE ll_relabel_pda_unit_fact SET occurred_at='2026-01-06 00:00:00' WHERE id=42");
|
||||
complete(50, 1, round, LocalDateTime.of(2026, 1, 7, 0, 0));
|
||||
// 旧轮次两个 SKU 均已扫描,也不能作为返工新轮次的商品件数证据。
|
||||
assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity());
|
||||
skuScan(51, 0, 5, round);
|
||||
skuScan(52, 1, 7, round);
|
||||
jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:01' WHERE id IN (51,52)");
|
||||
Snapshot before = snapshot();
|
||||
assertTrue(before.complete(), before.blockReasons().toString());
|
||||
assertEquals(round, before.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey());
|
||||
assertEquals(12L, before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity());
|
||||
|
||||
LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 8, 10, 30);
|
||||
customerConfirm(1, confirmedAt);
|
||||
Snapshot after = snapshot();
|
||||
assertTrue(after.complete(), after.blockReasons().toString());
|
||||
assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class));
|
||||
assertEquals(round, after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey());
|
||||
var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow();
|
||||
assertEquals(3, source.boundPackageCount());
|
||||
assertEquals(12L, source.productQuantity());
|
||||
assertEquals(List.of(42L), source.unitFacts().stream().map(RelabelDispatchSourcePort.UnitFact::id).toList());
|
||||
assertNotEquals(before.compositionVersion(), after.compositionVersion());
|
||||
}
|
||||
|
||||
/** 返工后不能借用旧绑定和旧作业事实;其他申请的本轮事实完整保留。 */
|
||||
@Test
|
||||
void reworkInvalidatesOldRoundBindingAndFacts() {
|
||||
Snapshot before = snapshot();
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')");
|
||||
jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at='2026-01-05 00:00:01', version=2 WHERE application_id=1");
|
||||
jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1");
|
||||
Snapshot after = snapshot();
|
||||
var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow();
|
||||
assertEquals(0, source.boundPackageCount());
|
||||
assertTrue(source.unitFacts().isEmpty());
|
||||
assertFalse(after.complete());
|
||||
assertNotEquals(before.compositionVersion(), after.compositionVersion());
|
||||
assertEquals(4, after.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount());
|
||||
binding(41, 1, "BOX-1", "PALLET-1", "INITIAL");
|
||||
jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41");
|
||||
assertEquals(0, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().boundPackageCount());
|
||||
}
|
||||
|
||||
/** 闭包递归扩展到新实托上的其他申请及它们的其他实托,不能只返回首申请。 */
|
||||
@Test
|
||||
void physicalApplicationClosureExpandsToFixedPoint() {
|
||||
position(102, 1002, "PALLET-2", 50);
|
||||
position(103, 1003, "PALLET-3", 50);
|
||||
item(12, 1, 102, "BOX-12", 1, 503, 7003);
|
||||
binding(30, 1, "BOX-12", "PALLET-2", "INITIAL");
|
||||
application(3, "RL-C", 9003);
|
||||
item(31, 3, 102, "BOX-31", 1, 504, 7004);
|
||||
item(32, 3, 103, "BOX-32", 1, 505, 7005);
|
||||
binding(31, 3, "BOX-31", "PALLET-2", "INITIAL");
|
||||
binding(32, 3, "BOX-32", "PALLET-3", "INITIAL");
|
||||
application(4, "RL-D", 9004);
|
||||
item(41, 4, 103, "BOX-41", 1, 506, 7006);
|
||||
binding(41, 4, "BOX-41", "PALLET-3", "INITIAL");
|
||||
Snapshot snapshot = snapshot();
|
||||
assertTrue(snapshot.complete(), snapshot.blockReasons().toString());
|
||||
assertEquals(List.of("RL-A", "RL-B", "RL-C", "RL-D"), snapshot.applications().stream()
|
||||
.map(RelabelDispatchSourcePort.Application::relabelNo).toList());
|
||||
assertEquals(6, snapshot.sources().size());
|
||||
assertEquals(List.of(1001L, 1002L, 1003L), snapshot.positions().stream()
|
||||
.map(RelabelDispatchSourcePort.Position::warehousePalletId).toList());
|
||||
}
|
||||
|
||||
/** 不按已完成状态、确认时间或运输方式删减原始关联申请。 */
|
||||
@Test
|
||||
void snapshotKeepsAllApplicationStatuses() {
|
||||
for (String status : List.of("PENDING_REVIEW", "PENDING_PROCESSING", "PROCESSING", "CUSTOMER_CONFIRM_PENDING",
|
||||
"READY_FOR_OUTBOUND", "REJECTED", "COMPLETED")) {
|
||||
jdbc.update("UPDATE ll_relabel_application SET status=?,delivery_method='OTHER',customer_confirmed_at=NULL WHERE id=2", status);
|
||||
Snapshot snapshot = snapshot();
|
||||
assertEquals(2, snapshot.applications().size());
|
||||
var app = snapshot.applications().stream().filter(a -> a.applicationId() == 2).findFirst().orElseThrow();
|
||||
assertEquals(status, app.status());
|
||||
assertEquals("OTHER", app.deliveryMethod());
|
||||
assertNull(app.customerConfirmedAt());
|
||||
assertEquals(2, snapshot.sources().size());
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅验证取消后持久状态:旧 SKU 指针保留,但不再作为新贡献或连接另一托上的活跃申请。 */
|
||||
@Test
|
||||
void cancelledSkuHistoryDoesNotContributeOrExpandThroughItsOtherPallet() {
|
||||
cancelledSkuHistoryWithReleasedHold();
|
||||
Snapshot current = snapshot();
|
||||
assertTrue(current.complete(), current.blockReasons().toString());
|
||||
assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList());
|
||||
assertEquals(List.of(21L), current.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList());
|
||||
assertEquals(List.of(1001L), current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList());
|
||||
assertEquals(4, current.positions().get(0).holdPackages());
|
||||
assertEquals(1, current.positions().get(0).availablePackages());
|
||||
Snapshot other = service.snapshot("1", List.of(1002L));
|
||||
assertTrue(other.complete(), other.blockReasons().toString());
|
||||
assertEquals(List.of("RL-C"), other.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList());
|
||||
assertEquals(List.of(31L), other.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList());
|
||||
assertEquals(List.of(1002L), other.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList());
|
||||
assertEquals(5, other.positions().get(0).holdPackages());
|
||||
assertEquals(1, other.positions().get(0).availablePackages());
|
||||
assertEquals("CANCELLED", jdbc.queryForObject("SELECT status FROM ll_relabel_application WHERE id=1", String.class));
|
||||
assertEquals(List.of(101L, 102L), jdbc.queryForList(
|
||||
"SELECT source_inventory_position_id FROM ll_relabel_item WHERE application_id=1 ORDER BY id", Long.class));
|
||||
}
|
||||
|
||||
/** 取消历史退出闭包不能隐去残余 HOLD,真实运输冻结服务仍须拒绝并回滚已经插入的贡献。 */
|
||||
@Test
|
||||
void cancelledHistoryCannotHideUnexplainedHoldFromActualTransportConsumer() throws Exception {
|
||||
cancelledSkuHistoryWithReleasedHold();
|
||||
jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101");
|
||||
Snapshot current = snapshot();
|
||||
assertTrue(current.complete(), current.blockReasons().toString());
|
||||
assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList());
|
||||
assertEquals(5, current.positions().get(0).holdPackages());
|
||||
assertEquals(4, current.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum());
|
||||
// provider 负责完整事实,实际运输消费者继续执行逐位置 HOLD 等量核对;这里不修改其规则。
|
||||
DispatchItemSourceService consumer = actualTransportConsumer();
|
||||
var root = new DispatchAggregate(); root.setId(77L); root.setOverseasWarehouseId("1");
|
||||
var selected = new DispatchItem(); selected.setId(88L); selected.setDispatchOrderId(77L);
|
||||
selected.setCustomerId("C1"); selected.setOrderId(9002L); selected.setWarehousePalletId(1001L);
|
||||
selected.setSourceBusinessType("RELABEL_ORDER"); selected.setSourceBusinessNo("RL-A");
|
||||
selected.setSourceStage("RELABEL"); selected.setPackageCount(4);
|
||||
BizException blocked = assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> {
|
||||
service.lockApplications(List.of("RL-A"));
|
||||
consumer.capture(root, List.of(selected), List.of(selected), LocalDateTime.of(2026, 1, 6, 0, 0));
|
||||
}));
|
||||
assertTrue(blocked.getMessage().contains("冻结箱数"));
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101", Long.class));
|
||||
}
|
||||
|
||||
/** 位置版本、逐箱事实及位置移动都会改变组成摘要,不能复用旧绑定依据。 */
|
||||
@Test
|
||||
void versionsReflectStockAndCompositionChanges() {
|
||||
Snapshot original = snapshot();
|
||||
assertEquals(original.compositionVersion(), snapshot().compositionVersion());
|
||||
jdbc.update("UPDATE ll_inventory_position SET version=version+1 WHERE id=101");
|
||||
Snapshot stockChanged = snapshot();
|
||||
assertNotEquals(original.compositionVersion(), stockChanged.compositionVersion());
|
||||
assertNotEquals(original.sources().get(0).bindingBasisHash(), stockChanged.sources().get(0).bindingBasisHash());
|
||||
jdbc.update("UPDATE ll_relabel_pda_unit_fact SET new_label='CHANGED' WHERE id=21");
|
||||
Snapshot workChanged = snapshot();
|
||||
assertNotEquals(stockChanged.compositionVersion(), workChanged.compositionVersion());
|
||||
position(102, 1002, "PALLET-2", 50);
|
||||
jdbc.update("UPDATE ll_relabel_item SET source_inventory_position_id=102 WHERE id=21");
|
||||
Snapshot moved = snapshot();
|
||||
assertNotEquals(workChanged.compositionVersion(), moved.compositionVersion());
|
||||
// 明细已经移出原托盘闭包;原托盘保留的申请仍有完整绑定,不能把已移走明细强塞回来。
|
||||
assertTrue(moved.complete(), moved.blockReasons().toString());
|
||||
assertEquals(List.of("RL-B"), moved.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList());
|
||||
assertTrue(moved.sources().stream().noneMatch(s -> s.itemId() == 21));
|
||||
// 显式读取新托盘后,实际位置与旧绑定目标不符,必须保留该明细并拒绝旧绑定。
|
||||
Snapshot includingMovedPallet = service.snapshot("1", List.of(1001L, 1002L));
|
||||
assertFalse(includingMovedPallet.complete());
|
||||
assertEquals(0, includingMovedPallet.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount());
|
||||
}
|
||||
|
||||
/** 无事务或未锁申请时必须拒绝锁后读取;锁顺序稳定且每事务只锁一次。 */
|
||||
@Test
|
||||
void callerTransactionOwnsOneOrderedApplicationLockSet() {
|
||||
assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A")));
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)));
|
||||
transaction.executeWithoutResult(status -> {
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)));
|
||||
service.lockApplications(List.of("RL-B", "RL-A", "RL-B"));
|
||||
assertEquals(List.of("RL-A", "RL-B"), lockCalls);
|
||||
service.lockApplications(List.of("RL-A", "RL-B"));
|
||||
assertEquals(2, lockCalls.size());
|
||||
assertEquals(2, service.lockedSnapshot("1", List.of(1001L)).applications().size());
|
||||
assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A")));
|
||||
});
|
||||
transaction.executeWithoutResult(status ->
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))));
|
||||
}
|
||||
|
||||
/** 锁后重读跳过 MyBatis 缓存,并拒绝锁集合以外新出现的申请。 */
|
||||
@Test
|
||||
void lockedReadRefreshesRowsAndRejectsNewApplication() {
|
||||
transaction.executeWithoutResult(status -> {
|
||||
Snapshot before = snapshot();
|
||||
service.lockApplications(List.of("RL-A", "RL-B"));
|
||||
jdbc.update("UPDATE ll_inventory_position SET version=2 WHERE id=101");
|
||||
Snapshot after = service.lockedSnapshot("1", List.of(1001L));
|
||||
assertEquals(2, after.positions().get(0).recordVersion());
|
||||
assertNotEquals(before.compositionVersion(), after.compositionVersion());
|
||||
application(3, "RL-C", 9003);
|
||||
item(31, 3, 101, "BOX-31", 1, 503, 7003);
|
||||
binding(31, 3, "BOX-31", "PALLET-1", "INITIAL");
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)));
|
||||
});
|
||||
}
|
||||
|
||||
/** 被挂起事务的锁凭证不能被新事务借用,恢复和回滚后凭证按事务清除。 */
|
||||
@Test
|
||||
void suspendedOrCompletedTransactionCannotLendItsLockProof() {
|
||||
transaction.executeWithoutResult(status -> {
|
||||
service.lockApplications(List.of("RL-A", "RL-B"));
|
||||
var nested = new TransactionTemplate(transactionManager);
|
||||
nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
nested.executeWithoutResult(inner ->
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))));
|
||||
assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete());
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
transaction.executeWithoutResult(status -> {
|
||||
assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)));
|
||||
service.lockApplications(List.of("RL-A", "RL-B"));
|
||||
assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete());
|
||||
});
|
||||
}
|
||||
|
||||
/** 客户隔离或绑定历史损坏时输出事实并显式阻止完整性声明。 */
|
||||
@Test
|
||||
void invalidOwnershipOrBindingHistoryBlocksCompleteness() {
|
||||
jdbc.update("UPDATE ll_relabel_item SET customer_id='C2' WHERE id=21");
|
||||
jdbc.update("UPDATE ll_relabel_pda_history SET detail_json='broken' WHERE id=10");
|
||||
Snapshot snapshot = snapshot();
|
||||
assertEquals(2, snapshot.sources().size());
|
||||
assertFalse(snapshot.complete());
|
||||
assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("归属")));
|
||||
assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("历史")));
|
||||
}
|
||||
|
||||
/** 仅用隔离 SQL 准备正式取消后的状态,不调用 changeStatus 或声称完整取消、财务链已经验收。 */
|
||||
private void cancelledSkuHistoryWithReleasedHold() {
|
||||
position(102, 1002, "PALLET-2", 6);
|
||||
item(12, 1, 102, "BOX-12", 1, 503, 7003);
|
||||
binding(30, 1, "BOX-12", "PALLET-2", "INITIAL");
|
||||
application(3, "RL-C", 9003);
|
||||
item(31, 3, 102, "BOX-31", 5, 504, 7004);
|
||||
binding(31, 3, "BOX-31", "PALLET-2", "INITIAL");
|
||||
jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL',notified_package_count=1,total_package_count=1,"
|
||||
+ "sku_relabel_items_json='[{\"quantity\":5}]' WHERE application_id=1");
|
||||
jdbc.update("DELETE FROM ll_relabel_pda_unit_fact WHERE id=12");
|
||||
fact(13, 1, 12, "RELABELLED");
|
||||
skuScan(90, 0, 5, "INITIAL");
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(91,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')",
|
||||
"{\"round\":\"INITIAL\",\"itemId\":\"12\",\"skuIndex\":0,\"quantity\":5}");
|
||||
complete(92, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0));
|
||||
jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101");
|
||||
jdbc.update("UPDATE ll_relabel_application SET status='CANCELLED',fulfillment_status='WAIT_CUSTOMER_CONFIRMATION',"
|
||||
+ "delivery_method=NULL,customer_confirmed_at=NULL WHERE id=1");
|
||||
jdbc.update("UPDATE ll_inventory_position SET hold_packages=hold_packages-1,available_packages=available_packages+1,"
|
||||
+ "version=version+1 WHERE id IN (101,102)");
|
||||
}
|
||||
|
||||
/** 新反例复用真实运输 Mapper、Repository、Service 和同一 H2 事务,不使用模拟拒绝门禁。 */
|
||||
private DispatchItemSourceService actualTransportConsumer() throws Exception {
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_item_source (
|
||||
id BIGINT PRIMARY KEY,dispatch_order_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL,
|
||||
customer_id VARCHAR(128) NOT NULL,relabel_application_id BIGINT NOT NULL,relabel_item_id BIGINT NOT NULL,
|
||||
relabel_round_key VARCHAR(128) NOT NULL,warehouse_pallet_id BIGINT NOT NULL,
|
||||
inventory_position_id BIGINT NOT NULL,inventory_balance_id BIGINT NOT NULL,
|
||||
source_inventory_balance_id BIGINT,source_inbound_order_id BIGINT,source_inbound_order_no VARCHAR(128),
|
||||
source_inbound_code VARCHAR(128),notified_package_count BIGINT NOT NULL,package_count BIGINT NOT NULL,
|
||||
product_quantity BIGINT,binding_basis_hash CHAR(64) NOT NULL,unit_facts_json CLOB NOT NULL,created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id))
|
||||
""");
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(DispatchItemSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(transactionManager.getDataSource()); factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class);
|
||||
return new DispatchItemSourceService(new DispatchItemSourceRepository(mapper), service, new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
/** 创建同客户申请及独立作业任务,不借用履约订单作为原货源。 */
|
||||
private void application(long id, String no, long fulfillmentOrderId) {
|
||||
jdbc.update("INSERT INTO ll_relabel_application VALUES(?,'C1',?,?,'1','TRUCK','READY_FOR_OUTBOUND',"
|
||||
+ "'WAIT_PRE_DISPATCH','2026-01-04 00:00:00','2026-01-04 00:00:00')", id, no, fulfillmentOrderId);
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_task VALUES(?,?,'C1',?,1,'WAIT_REVIEW',NULL,1)", 1000 + id, id, no);
|
||||
}
|
||||
|
||||
/** 创建真实库存位置,保留可用、HOLD 和版本的独立事实。 */
|
||||
private void position(long id, long palletId, String palletNo, long hold) {
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(?,'C1',600,'1',?,?,0,?,0,0,0,1)",
|
||||
id, palletId, palletNo, hold);
|
||||
}
|
||||
|
||||
/** 创建逐明细独立冻结来源的普通箱标任务。 */
|
||||
private void item(long id, long appId, long positionId, String mark, int packages, long sourceBalance, long inbound) {
|
||||
jdbc.update("INSERT INTO ll_relabel_item VALUES(?,?,'C1',?,?,?,'CARTON_LABEL',NULL,NULL,?,?,?,?,?)",
|
||||
id, appId, packages, packages, mark, sourceBalance, positionId, inbound, "IN-" + inbound, "CODE-" + inbound);
|
||||
}
|
||||
|
||||
/** 写入专用组托的当前轮次历史,箱唛与实托码严格配对。 */
|
||||
private void binding(long id, long appId, String mark, String palletNo, String round) {
|
||||
String detail = "{\"round\":\"" + round + "\",\"cartonMarks\":[\"" + mark
|
||||
+ "\"],\"targetPallets\":[\"" + palletNo + "\"]}";
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','PALLET_BIND',?,'2026-01-02 00:00:00')",
|
||||
id, 1000 + appId, appId, detail);
|
||||
}
|
||||
|
||||
/** 写入箱级成功或异常事实,quantity=1 明确表示箱作业而非商品件数。 */
|
||||
private void fact(long id, long appId, long itemId, String result) {
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(?,?,?,?,'C1',?,?,?,1,'2026-01-03 00:00:00')",
|
||||
id, 1000 + appId, appId, itemId, "OLD-" + id, "NEW-" + id, result);
|
||||
}
|
||||
|
||||
/** 写入商品件数的实际逐 SKU 扫描证据。 */
|
||||
private void skuScan(long id, int index, int quantity, String round) {
|
||||
String detail = "{\"round\":\"" + round + "\",\"itemId\":\"11\",\"skuIndex\":" + index
|
||||
+ ",\"quantity\":" + quantity + "}";
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')", id, detail);
|
||||
}
|
||||
|
||||
/** 写入生产者 COMPLETE 的 roundKey,不以客户确认时更新的任务字段替代。 */
|
||||
private void complete(long id, long appId, String round, LocalDateTime occurredAt) {
|
||||
jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','COMPLETE',?,?)", id, 1000 + appId,
|
||||
appId, "{\"roundKey\":\"" + round + "\"}", occurredAt);
|
||||
}
|
||||
|
||||
/** 按正式客户确认路径重写 reviewedAt、任务状态及申请确认字段。 */
|
||||
private void customerConfirm(long appId, LocalDateTime confirmedAt) {
|
||||
jdbc.update("UPDATE ll_relabel_pda_task SET status='COMPLETED',reviewed_at=?,version=version+1 WHERE application_id=?",
|
||||
confirmedAt, appId);
|
||||
jdbc.update("UPDATE ll_relabel_application SET status='READY_FOR_OUTBOUND',fulfillment_status='WAIT_PRE_DISPATCH',"
|
||||
+ "customer_confirmed_at=?,updated_at=? WHERE id=?", confirmedAt, confirmedAt, appId);
|
||||
}
|
||||
|
||||
/** 从一个实托调用实际生产事实提供者。 */
|
||||
private Snapshot snapshot() {
|
||||
return service.snapshot("1", List.of(1001L));
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -513,7 +513,7 @@ class WarehouseTransferServiceTest {
|
||||
verifyNoInteractions(transfers);
|
||||
}
|
||||
|
||||
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
|
||||
/** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */
|
||||
@Test
|
||||
void submittingOldDraftRechecksRelabelCartonOwnership() {
|
||||
var transfer = transfer(WarehouseTransferService.DRAFT);
|
||||
@@ -521,9 +521,6 @@ class WarehouseTransferServiceTest {
|
||||
transfer.setInboundCode("IN-1");
|
||||
transfer.setNeedRelabel(true);
|
||||
transfer.setPackageCount(1);
|
||||
var selected = new WarehouseTransferCarton();
|
||||
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
|
||||
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected));
|
||||
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
|
||||
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
|
||||
Reference in New Issue
Block a user