This commit is contained in:
2026-10-20 10:35:52 +08:00
parent ce189f062f
commit c13175bf77
202 changed files with 38397 additions and 7138 deletions
@@ -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) {
@@ -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() {
@@ -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,
@@ -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);
@@ -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,
@@ -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)");
@@ -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);
@@ -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);
@@ -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();
@@ -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())));
}
}
}