fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:00 +08:00
parent f701456de1
commit a16a85e14a
85 changed files with 1518 additions and 277 deletions
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/** 核对分页、总数、分组统计及BOL接入同一正式编号查询;JSON函数适配仅用于隔离H2测试。 */
public class DispatchReceivingSqlTest {
/** 三种列表查询必须在SQL分页前使用当前客户确认,且保留非空人工编号的覆盖条件。 */
@Test void allListQueriesUseCurrentReceivingAuthorityBeforePagination() {
var config=new MybatisConfiguration();config.addMapper(DispatchAggregateMapper.class);
var query=new DispatchAggregateMapper.Search(null,null,null,null,"ISA-CURRENT","ALL",null,null,null,0,20);
for(var method:new String[]{"page","count","summaries"}) {
var bound=config.getMappedStatement(DispatchAggregateMapper.class.getName()+"."+method).getBoundSql(Map.of("q",query));
var sql=normalize(bound.getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" =?");
assertThat(sql).doesNotContain("ll_inbound_fba_appointment ap","ap.confirmation_no");
assertThat(sql).contains("saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL");
assertThat(bound.getParameterMappings().stream().filter(p->"q.fbaAppointmentNo".equals(p.getProperty())).count()).isEqualTo(2);
if(method.equals("page"))assertThat(sql.indexOf("LIMIT")).isGreaterThan(sql.indexOf("ll_fba_receiving_member"));
}
}
/** BOL只替换编号来源,仍由原批次item核对客户、来源和订单映射。 */
@Test void bolUsesSameCurrentConfirmationAndKeepsSourceMapping() {
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);
var sql=normalize(config.getMappedStatement(DispatchBolMapper.class.getName()+".findAppointmentAllocations")
.getBoundSql(Map.of("dispatchOrderId",1L)).getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" AS confirmationNo");
assertThat(sql).contains("i.customer_id=a.customer_id","i.source_key=a.source_key","i.order_id AS orderId");
assertThat(sql).doesNotContain("p.confirmation_no");
}
/** H2没有MySQL JSON函数;仅适配标量路径读取,SQL关联、过滤和数量计算仍执行真实Mapper。 */
public static void installJsonFunctions(JdbcTemplate db) {
db.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonExtract'");
db.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonUnquote'");
}
/** 区分缺失路径的SQL NULL与JSON显式null,覆盖未填写确认时间的两种存储形态。 */
public static String jsonExtract(String document,String path) throws Exception {
if(document==null)return null;
var node=new ObjectMapper().readTree(document);
for(var key:path.substring(2).split("\\."))node=node.path(key);
return node.isMissingNode()?null:node.toString();
}
/** 与MySQL标量JSON_UNQUOTE一致,保留JSON null文本供生产SQL判断。 */
public static String jsonUnquote(String value) throws Exception {
if(value==null)return null;
var node=new ObjectMapper().readTree(value);
return node.isTextual()?node.textValue():node.toString();
}
/** 去除文本块换行差异,仅比较实际SQL表达式。 */
private static String normalize(String sql) {return sql.replaceAll("\\s+"," ").trim();}
}
@@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
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"),List.of())), null);
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of(),mode==ResourceMode.MASTER && !ids.isEmpty()?ids.get(0):null)), null);
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
}
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -374,6 +374,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
@@ -403,6 +404,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
@@ -428,6 +430,7 @@ public class DispatchAggregatePersistenceTest {
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
@@ -505,7 +508,7 @@ public class DispatchAggregatePersistenceTest {
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().stream().filter(a0->"FBA:ONT8".equals(a0.destinationKey())).findFirst().orElseThrow();
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
var c=f.source(3,"101",503,"ONT8",2);
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
@@ -588,19 +591,20 @@ public class DispatchAggregatePersistenceTest {
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,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_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128),order_type VARCHAR(64))");
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),destination_warehouse_code 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)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(64),carton_mark VARCHAR(128),pallet_code VARCHAR(128),relabel_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),total_quantity BIGINT,status VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128),lastleg_order_id BIGINT,fba_shipment_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,carton_count INT,status VARCHAR(32),completed_at TIMESTAMP(3),gross_weight_kg DECIMAL(18,6),volume_cbm DECIMAL(18,6))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(64),pallet_id BIGINT,created_at TIMESTAMP(3),business_type VARCHAR(64))");
db.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(32),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(128),customer_id VARCHAR(128),source_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,version INT DEFAULT 0,appointment_no VARCHAR(64),confirmation_no VARCHAR(128),appointment_status VARCHAR(32),fba_warehouse_code VARCHAR(128),appointment_start_utc TIMESTAMP(3))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT,appointment_id BIGINT,source_key VARCHAR(128),fba_warehouse_code VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),fba_warehouse_code VARCHAR(128))");
json=new ObjectMapper().registerModule(new JavaTimeModule());
var fleetRepo=new FleetTripRepository(session.getMapper(FleetTripMapper.class),session.getMapper(FleetResourceMapper.class),session.getMapper(FleetTripHistoryMapper.class),session.getMapper(FleetTripDispatchMapper.class));
var warehouses=mock(FleetWarehouseDirectory.class); when(warehouses.required(9L,"OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("9","WH9","测试起运仓","OVERSEAS"));
@@ -632,7 +636,9 @@ public class DispatchAggregatePersistenceTest {
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
@@ -663,20 +669,32 @@ public class DispatchAggregatePersistenceTest {
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of(303L))),
departure,null,null,sources,List.of(), true);
}
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
prepareAppointmentNumbers(created.dispatchNo());
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null)));
}
/** 普通装车夹具预置已约编号;预约缺失及填写流程由专项测试单独验证。 */
public void prepareAppointmentNumbers(String number) {
try {
var row=repository.find(number);var resources=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(row.getResourcesJson());
var maintenance=resources.withObject("/maintenance");var entries=maintenance.withArray("destinationAppointments");
var known=new java.util.HashSet<String>();entries.forEach(e->known.add(e.path("destinationKey").asText()));
for(var item:repository.cargo(row.getId()))if("FBA".equals(item.getDestinationType()) && known.add("FBA:"+item.getFbaWarehouseCode()))
entries.addObject().put("destinationKey","FBA:"+item.getFbaWarehouseCode()).put("appointmentNo","TEST-ISA-"+item.getFbaWarehouseCode());
db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",json.writeValueAsString(resources),number);
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException(error);}
}
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
public void picked(long source,String customer,long pallet,int packages) {
long item=db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,source);
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id) 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(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2);
@@ -4,6 +4,7 @@ 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.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
@@ -23,6 +24,78 @@ import static org.mockito.Mockito.*;
/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */
class DispatchBolReadPersistenceTest {
/** 同一批次仅客户A确认,批次头仍为空,BOL只给A带出当前编号且不改变两方箱数。 */
@Test void partialBatchConfirmationUsesCurrentCustomerAuthority() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-CURRENT","101","ORDER:1000",1000,"ONT8");
f.db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(2,1,'ORDER:2000','102',2000,'ONT8')");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,1,"102","ORDER:2000","FBA:ONT8",5);
var lines=f.read().bolLines();
assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-CURRENT",null);
assertThat(lines).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no='ISA-STALE'");
assertThat(f.read().bolLines()).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-CURRENT",null);
}
/** 当前覆盖退出、失效或确认资料不完整时,不借用历史编号、其他客户或其他仓的编号。 */
@Test void invalidReceivingConfirmationCannotProvideBolNumber() throws Exception {
for(var change:List.of(
"UPDATE ll_fba_receiving_member SET active_ref=NULL",
"UPDATE ll_fba_receiving_member SET customer_id='102'",
"UPDATE ll_fba_receiving_member SET batch_key='FBA_APPOINTMENT:2'",
"UPDATE ll_fba_receiving_agreement SET destination_key='FBA:LAX9'",
"UPDATE ll_fba_receiving_agreement SET status='CANCELLED'",
"UPDATE ll_fba_receiving_agreement SET status='SUPERSEDED'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"PENDING\",\"appointmentAt\":\"2026-10-22T10:00:00\"}}'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"SUCCESS\",\"appointmentAt\":null}}'",
"UPDATE ll_fba_receiving_agreement SET snapshot_json='{\"details\":{\"appointmentStatus\":\"SUCCESS\"}}'")) {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-CURRENT","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no='ISA-STALE'");f.db.update(change);
assertThat(f.read().bolLines()).as(change).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isEqualTo(3);
});
}
}
/** 同一客户改约后历史关联仍保留,打印只取新的有效关联,不重复分配货量。 */
@Test void replacedReceivingMemberUsesOnlySuccessorNumber() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-OLD","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
f.db.update("UPDATE ll_fba_receiving_member SET active_ref=NULL");
f.db.update("INSERT INTO ll_fba_receiving_agreement SELECT 'RA-NEW',status,destination_key,'ISA-NEW',snapshot_json FROM ll_fba_receiving_agreement");
f.db.update("INSERT INTO ll_fba_receiving_member VALUES('RA-NEW','FBA_APPOINTMENT:1','101','FBA_APPOINTMENT:1/101')");
assertThat(f.read().bolLines()).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isEqualTo("ISA-NEW");assertThat(line.packageCount()).isEqualTo(3);
});
}
/** 同一车目的仓填写一次编号,打印逐行归属及各客户原数量保持不变。 */
@Test void destinationNumberAppliesToAllCustomersWithoutDuplicatingCargo() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
f.appointment(1,"ISA-OLD-A","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-OLD-B","102","ORDER:2000",2000,"ONT8");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:ONT8",5);
f.row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null,
new DispatchContracts.Maintenance(null,null,null,null,null,null,null,List.of(new DispatchContracts.DestinationAppointment("FBA:ONT8","ISA-CAR"))))));
var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102");
assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);
assertThat(result.bolLines()).allSatisfy(line->assertThat(line.appointmentNo()).isEqualTo("ISA-CAR"));
}
/** 先保存空目的仓草稿,再取得正式预约号时,打印仍带出正式编号。 */
@Test void blankDestinationDraftDoesNotEraseLaterOfficialNumber() throws Exception {
for(var blank:Arrays.asList(null,""," ")) {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);
f.row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null,
new DispatchContracts.Maintenance(null,null,null,null,null,null,null,List.of(new DispatchContracts.DestinationAppointment("FBA:ONT8",blank))))));
assertThat(f.read().bolLines()).singleElement().satisfies(line->assertThat(line.appointmentNo()).isNull());
f.appointment(1,"ISA-REAL","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);
assertThat(f.read().bolLines()).singleElement().satisfies(line->{
assertThat(line.appointmentNo()).isEqualTo("ISA-REAL");assertThat(line.packageCount()).isEqualTo(3);assertThat(line.customerId()).isEqualTo("101");
});
}
}
/** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */
@Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception {
var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0);
@@ -108,7 +181,7 @@ class DispatchBolReadPersistenceTest {
f.appointment(1,"SAME","101","ORDER:1000",1000,"ONT8");f.appointment(2,"SAME","102","ORDER:2000",2000,"LAX9");
f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:LAX9",5);
assertThat(f.read().bolLines().stream().map(DispatchBolData.Line::groupKey).distinct().count()).isEqualTo(2);
f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no=' '");
f.db.update("UPDATE ll_fba_receiving_agreement SET confirmation_no=' '");
assertThat(f.read().bolLines()).allSatisfy(line->{assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isPositive();});
}
@@ -173,10 +246,13 @@ class DispatchBolReadPersistenceTest {
/** 测试表仅取完整模块DDL中实际引用列,保留来源/分配唯一键,事务覆盖两个真实Mapper查询。 */
private static final class Fixture {
final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;
final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx;final DispatchAggregate row;
/** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */
Fixture() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds);
DispatchReceivingSqlTest.installJsonFunctions(db);
db.execute("CREATE TABLE ll_fba_receiving_agreement(agreement_key VARCHAR(64) PRIMARY KEY,status VARCHAR(24),destination_key VARCHAR(128),confirmation_no VARCHAR(128),snapshot_json VARCHAR(4000))");
db.execute("CREATE TABLE ll_fba_receiving_member(agreement_key VARCHAR(64),batch_key VARCHAR(128),customer_id VARCHAR(128),active_ref VARCHAR(260) UNIQUE,PRIMARY KEY(agreement_key,batch_key,customer_id))");
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(128),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),source_stage VARCHAR(32),order_id BIGINT,destination_type VARCHAR(32),delivery_address VARCHAR(1000),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT,counted_package_count INT,UNIQUE(line_id,pallet_id))");
@@ -186,7 +262,7 @@ class DispatchBolReadPersistenceTest {
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))");
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, null)));when(roots.find("DP-ONE")).thenReturn(row);
var roots=mock(DispatchAggregateRepository.class);row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row);
service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
}
@@ -201,10 +277,13 @@ class DispatchBolReadPersistenceTest {
db.update("INSERT INTO ll_order VALUES(?,?,?)",order,customer,"CN-"+order);
db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,NULL,NULL,?,?, 'FBA',NULL,NULL,?,?,?,?,?,?)",item,customer,pallet,stage,order,"P-"+pallet,code,code,code,"IN-"+order,packages);
}
/** 系统预约业务号与对外确认号分开,稳定来源键原样保存。 */
/** 系统批次头可为空;正式编号来自当前客户收货确认,稳定来源键原样保存。 */
void appointment(long id,String confirmation,String customer,String source,long order,String code) {
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,?)",id,"SYSTEM-APPOINTMENT-"+id,confirmation);
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,NULL)",id,"SYSTEM-APPOINTMENT-"+id);
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(?,?,?,?,?,?)",id,id,source,customer,order,code);
db.update("INSERT INTO ll_fba_receiving_agreement VALUES(?,'ACTIVE',?,?,?)","RA-"+id,"FBA:"+code,confirmation,
"{\"details\":{\"appointmentStatus\":\"SUCCESS\",\"appointmentAt\":\"2026-10-22T10:00:00\"}}");
db.update("INSERT INTO ll_fba_receiving_member VALUES(?,?,?,?)","RA-"+id,"FBA_APPOINTMENT:"+id,customer,"FBA_APPOINTMENT:"+id+"/"+customer);
}
/** 按模块实际主键建立本车明细的真实逐票分配,不以客户目的地总量替代。 */
void allocate(long item,long pallet,long appointment,String customer,String source,String destination,int packages) {
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 真实排车持久化验证先排后约、同仓跨客户、缺约拒审及补齐后继续。 */
class DispatchDestinationAppointmentPersistenceTest {
@Test void canCreateWithoutAppointmentAndSubmitAfterOneNumberForTwoCustomers() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"ONT8",5)));
var created=f.tx.execute(t->f.service.create(f.actor,input));
assertThat(f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments()).hasSize(1);
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"missing",null)))).hasMessageContaining("ONT8");
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"dest-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
var loaded=f.approve(saved);assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));
assertThat(detail.dispatchInfo().fbaAppointments()).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE"));
assertThat(detail.orderDetails()).extracting(d->d.customerId()).containsExactlyInAnyOrder("101","202");
}
@Test void savingOneOfTwoNumbersIsAllowedButReviewNeedsBoth() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var input=f.configured("two-dest-create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LGB8",5)));
var created=f.tx.execute(t->f.service.create(f.actor,input));
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"partial-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"))))));
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"second-missing",null)))).hasMessageContaining("LGB8");
var complete=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(saved.recordVersion(),"complete-save",input.vehicle(),input.driver(),input.plannedDepartureAt(),null,null,true,new Maintenance(null,null,null,null,null,null,null,List.of(new DestinationAppointment("FBA:ONT8","ISA-ONE"),new DestinationAppointment("FBA:LGB8","ISA-TWO"))))));
assertThat(f.approve(complete).stage()).isEqualTo(Stage.LOADING);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchViews.FbaAppointment;
import java.util.*;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
class DispatchDestinationAppointmentsTest {
@Test void groupsMultipleCustomersAndPalletsByDestination() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).contains("LGB8、ONT8");
}
/** 十票跨客户货物同目的仓,只带入一个编号,全部不同货件保留。 */
@Test void takesTheUniqueAlreadyConfirmedNumberAndAggregatesShipments() {
var customers=java.util.stream.IntStream.range(0,10).mapToObj(i->"A"+i).toList();
var rows=resolve(customers.stream().map(c->item(c,"ONT8")).toList(),null,customers.stream().map(c->booked(c,"ISA-1")).toList());
assertThat(rows).singleElement().satisfies(a->{assertThat(a.appointmentNo()).isEqualTo("ISA-1");assertThat(a.shipmentNumbers()).containsExactlyElementsOf(customers.stream().map(c->"SHIP-"+c).toList());assertThat(a.customerId()).isNull();});
}
@Test void conflictingExistingNumbersRequireOneExplicitDestinationNumber() {
var cargo=List.of(item("A","ONT8"),item("B","ONT8"));var booked=List.of(booked("A","ISA-A"),booked("B","ISA-B"));
assertThat(resolve(cargo,null,booked).get(0).appointmentNo()).isNull();
var rows=resolve(cargo,maintenance(new DestinationAppointment("FBA:ONT8"," ISA-CAR ")),booked);
assertThat(rows).singleElement().satisfies(a->{assertThat(a.appointmentNo()).isEqualTo("ISA-CAR");assertThat(a.confirmedAt()).isNull();});
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).isNull();
}
/** 先保存空草稿、后取得正式预约时,同仓仍自动带出一次,无需重新手填。 */
@Test void blankDraftDoesNotHideTheUniqueConfirmedNumber() {
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked("A","ISA-ONE"),booked("B","ISA-ONE")));
assertThat(rows).singleElement().satisfies(a->assertThat(a.appointmentNo()).isEqualTo("ISA-ONE"));
}
@Test void missingSecondDestinationDoesNotPassWithTheFirstNumber() {
var rows=resolve(List.of(item("A","ONT8"),item("B","LGB8")),maintenance(new DestinationAppointment("FBA:ONT8","ISA-1")),List.of());
assertThat(DispatchDestinationAppointments.blockReason(rows,null,null,List.of())).contains("LGB8").doesNotContain("ONT8");
assertThatThrownBy(()->DispatchDestinationAppointments.validate(maintenance(new DestinationAppointment("FBA:OTHER","ISA")),rows)).hasMessageContaining("本车已有");
}
@Test void rejectsDuplicateDestinationsAndOverlongNumbers() {
assertThatThrownBy(()->maintenance(new DestinationAppointment("FBA:ONT8","A"),new DestinationAppointment("FBA:ONT8","B"))).hasMessageContaining("一个预约号");
assertThatThrownBy(()->new DestinationAppointment("FBA:ONT8","A".repeat(129))).hasMessageContaining("128");
}
@Test void knownConfirmedWindowKeepsDepartureAndArrivalValidation() {
var at=java.time.Instant.parse("2026-11-01T18:00:00Z");
var rows=resolve(List.of(item("A","ONT8")),null,List.of(booked("A","ISA-1")));
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-1",at,at.plusSeconds(3600),"AGR-1",1);
assertThat(DispatchDestinationAppointments.blockReason(rows,at.minusSeconds(3600),at.plusSeconds(1800),List.of(window))).isNull();
assertThat(DispatchDestinationAppointments.blockReason(rows,at.plusSeconds(60),at.plusSeconds(120),List.of(window))).contains("早于");
assertThat(DispatchDestinationAppointments.blockReason(rows,at.minusSeconds(3600),at.plusSeconds(3601),List.of(window))).contains("晚于");
}
/** 将测试中的货物引用显式构造为真实货物投影,预约编号仅提供确认事实。 */
private static List<FbaAppointment> resolve(List<DispatchItem> cargo,Maintenance maintenance,List<FbaAppointment> booked) {
var shipments=booked.stream().flatMap(b->b.shipmentNumbers().stream().map(no->new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment(b.customerId(),b.destinationKey(),no))).toList();
return DispatchDestinationAppointments.resolve(cargo,maintenance,booked,shipments);
}
private static DispatchItem item(String customer,String code){var item=new DispatchItem();item.setCustomerId(customer);item.setDestinationType("FBA");item.setFbaWarehouseCode(code);return item;}
private static FbaAppointment booked(String customer,String no){return new FbaAppointment("FBA_APPOINTMENT:1","AP-1",no,"FBA:ONT8","ONT8","SUCCESS",null,null,customer,List.of("SHIP-"+customer));}
private static Maintenance maintenance(DestinationAppointment... appointments){return new Maintenance(null,null,null,null,null,null,null,List.of(appointments));}
}
@@ -0,0 +1,134 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */
class DispatchFeedbackRegressionTest {
/** 主档PDF与本趟补传图片分属各自文件域,补传不能被主档重建清空。 */
@Test void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
var driver=mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class);
var details=mock(com.baiyue.lastleg.fleet.driver.DriverModels.Draft.class);
when(driver.id()).thenReturn("71");when(driver.details()).thenReturn(details);when(driver.licenseFileId()).thenReturn("81");
when(details.region()).thenReturn("OVERSEAS");when(details.name()).thenReturn("主档司机");when(details.phone()).thenReturn("555-0100");
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1));
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3}));
var input=f.configured("update",List.of());
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))),
input.plannedDepartureAt(),null,null,true,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
f.prepareAppointmentNumbers(created.dispatchNo());
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(proof.fileIds()).containsExactly(81L,91L);assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion());
verify(drivers,never()).download(71L,91L);
}
/** 车辆候选使用真实资源占用,本车已选资源保留,其他车次占用排除。 */
@Test void vehicleAvailabilityKeepsOnlyOwnReservation() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var first=f.tx.execute(s->f.service.create(f.actor,f.create("first",List.of())));
var second=f.tx.execute(s->f.service.create(f.actor,f.create("second",List.of())));
long own=f.repository.find(first.dispatchNo()).getTripId(), other=f.repository.find(second.dispatchNo()).getTripId();
f.db.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,?,'OWN'),('VEHICLE',12,?,'OTHER')",own,other);
var guard=new com.baiyue.lastleg.fleet.trip.FleetVehicleAssignmentGuard(f.session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class));
assertThat(guard.unavailableForDispatch(first.dispatchNo())).containsExactly(12L);
assertThat(guard.unavailableForDispatch(second.dispatchNo())).containsExactly(11L);
assertThat(guard.unavailableForDispatch("UNRELATED")).containsExactlyInAnyOrder(11L,12L);
}
/** 三类各自缺失均不能提交,不能用其他类别照片顶替。 */
@ParameterizedTest @ValueSource(strings={"VEHICLE","DRIVER_PHOTO","DRIVER"})
void missingPhotoBlocksSubmission(String kind) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("missing-"+kind,List.of(f.source(1,"101",501,"ONT8",3)))));
f.prepareAppointmentNumbers(created.dispatchNo());
var row=f.repository.find(created.dispatchNo());
var json=(ObjectNode)f.json.readTree(row.getResourcesJson());
ObjectNode target=kind.equals("VEHICLE")?(ObjectNode)json.path("vehicle"):(ObjectNode)json.path("driver").path("evidence");
target.set(kind.equals("VEHICLE")?"evidenceFileIds":kind.equals("DRIVER")?"fileIds":"photoFileIds",f.json.valueToTree(List.of()));
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=?",f.json.writeValueAsString(json),row.getId());
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))))
.hasMessageContaining("至少一张有效");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(created.recordVersion());
}
/** 上传未成功不产生有效引用;草稿可先保存空照片,送审才拦截。 */
@Test void incompletePhotosCanBeSavedAsDraft() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.create("draft",List.of(f.source(1,"101",501,"ONT8",3)))));
var input=f.configured("resource",List.of());var v=input.vehicle();var d=input.driver();var e=d.evidence();
var update=new DispatchCommands.Update(created.recordVersion(),"save-draft",
new DispatchCommands.Vehicle(v.mode(),null,v.plate(),v.carrierName(),v.capacity(),List.of()),
new DispatchCommands.Driver(d.mode(),null,d.name(),d.phone(),new DispatchContracts.DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),List.of(),e.expectedTaskEndAt(),List.of())),
input.plannedDepartureAt(),null,null,true,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
f.prepareAppointmentNumbers(created.dispatchNo());
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-draft",null))))
.hasMessageContaining("车辆图片");
}
/** PDF不计为图片,失效文件在审核通过时重新核验,失败保持待审。 */
@Test void pdfAndRevokedUploadCannotPassReview() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("valid",List.of(f.source(1,"101",501,"ONT8",3)))));
f.prepareAppointmentNumbers(created.dispatchNo());
var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
when(gateway.metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq(101L)))
.thenReturn(new DispatchEvidenceGateway.File(101L,"车辆.pdf","application/pdf",10));
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"pdf",null))))
.hasMessageContaining("车辆图片");
when(gateway.metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq(101L)))
.thenReturn(new DispatchEvidenceGateway.File(101L,"车辆.png","image/png",10));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-valid",null)));
doThrow(new IllegalArgumentException("文件已作废")).when(gateway).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),eq(303L));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve-revoked",Decision.APPROVED,null,null))))
.hasMessageContaining("文件已作废");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("DISPATCH_PENDING");
}
/** 同仓预约汇总一行,多托及重复分配不重复显示,全部真实货件保留。 */
@Test void appointmentsGroupByDestinationAndKeepAllShipmentNumbers() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("projection",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"101",502,"ONT8",2)))));
long id=f.repository.find(created.dispatchNo()).getId();
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class);
f.db.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,fba_warehouse_code) VALUES(900,'AP-1','ONT8'),(901,'AP-2','ONT8')");
f.db.update("INSERT INTO ll_order(id,customer_id,order_no,order_type) VALUES(9001,'101','LM-1','FIRST_LEG'),(9002,'101','LM-2','FIRST_LEG')");
f.db.update("INSERT INTO ll_order_detail VALUES(9001,'101','SHIP-1','ONT8'),(9001,'101','SHIP-2','ONT8'),(9001,'101','SHIP-1','ONT8'),(9001,'999','FOREIGN','ONT8'),(9002,'101','SHIP-3','ONT8')");
f.db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(9001,'101',1,900,'ORDER:9001','ONT8'),(9002,'101',1,900,'ORDER:9002','ONT8'),(9001,'101',1,901,'ORDER:9001','ONT8')");
for(String source:List.of("ORDER:9001","ORDER:9001","ORDER:9002"))
f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101',?,'FBA:ONT8',1)",id,item,source);
f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:901','101','ORDER:9001','FBA:ONT8',1)",id,item);
var provider=mock(AppointmentWorkDispatchPort.class);var at=Instant.parse("2026-10-25T17:00:00Z");
when(provider.reviewEvidence(created.dispatchNo())).thenReturn(List.of(
new AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-1","101","FBA:ONT8","CONF-SAME",at,null,"AGR-1",1),
new AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:901","AP-2","101","FBA:ONT8","CONF-SAME",at.plusSeconds(3600),null,"AGR-2",1)));
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var appointments=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())).dispatchInfo().fbaAppointments();
assertThat(appointments).hasSize(1);
assertThat(appointments.get(0).shipmentNumbers()).containsExactly("SHIP-1","SHIP-2","SHIP-3");
assertThat(appointments.get(0).confirmedAt()).isEqualTo(at);
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,10,null,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
assertThat(page.records().get(0).fbaAppointments()).isEqualTo(appointments);
}
}
@@ -82,13 +82,13 @@ class DispatchInboundReceiptPersistenceTest {
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效");
}
/** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */
@Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception {
/** 本车已按目的仓维护编号,逐票预约回复不再重新引入车辆送审门禁。 */
@Test void receivingKeepsFactsWithoutReintroducingPerTicketAppointmentGate() throws Exception {
var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true);
f.confirm();
assertThat(f.quantities()).containsExactly(9,11);
assertThat(f.allocations()).containsExactly(9,11);
assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认");
assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException();
}
/** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */
@@ -161,8 +161,8 @@ class DispatchInboundReceiptPersistenceTest {
doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id);
doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any());
}
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))");
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD destination_type VARCHAR(20)");
p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (planned_package_count INT,counted_package_count INT)");
p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')");
for(long id=1;id<=2;id++) {
p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id);
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.fleet.driver.*;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 创建时间公共投影、实际货件来源及司机换证保存的直接回归。 */
class DispatchListAndResourceRegressionTest {
/** 各阶段、审核分区和任务类型均取同一主单创建时间,不借用计划或更新时间。 */
@Test void creationTimeIsPreservedAcrossAllListScopes() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var createdAt=LocalDateTime.of(2026,10,22,1,23,45);
for(var stage:Stage.values())for(var type:List.of("DELIVERY","PICKUP","LOCAL_TRUCK")) {
var result=f.tx.execute(s->f.service.create(f.actor,f.create(stage+type,List.of())));
f.db.update("UPDATE ll_dispatch_order SET task_type=?,stage=?,created_at=?,planned_departure_at=?,updated_at=? WHERE dispatch_no=?",
type,stage.name(),createdAt,createdAt.plusDays(1),createdAt.plusDays(2),result.dispatchNo());
}
for(var stage:Stage.values()) {
var page=f.tx.execute(s->f.service.page(f.actor,query(stage,null)));
assertThat(page.records()).hasSize(3).allSatisfy(row->assertThat(row.createdAt()).isEqualTo(createdAt.toInstant(ZoneOffset.UTC)));
}
for(var section:BeforeLoadingSection.values()) {
var state=section==BeforeLoadingSection.VEHICLE_INFO?"DRAFT":section==BeforeLoadingSection.DISPATCH_REVIEW?"DISPATCH_PENDING":"LOADING_PENDING";
f.db.update("UPDATE ll_dispatch_order SET review_state=? WHERE stage='BEFORE_LOADING'",state);
var page=f.tx.execute(s->f.service.page(f.actor,query(Stage.BEFORE_LOADING,section)));
assertThat(page.records()).hasSize(3).allSatisfy(row->assertThat(row.createdAt()).isEqualTo(createdAt.toInstant(ZoneOffset.UTC)));
}
}
/** 手填预约与内部实物引用不妨碍逐票货件读取,同仓跨客户去重且不串入别的客户。 */
@Test void manualAppointmentReadsShipmentsFromActualPlanAllocations() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var result=f.tx.execute(s->f.service.create(f.actor,f.configured("actual-shipments",List.of(f.source(1,"101",501,"ONT8",2),f.source(2,"202",502,"ONT8",1)))));
f.prepareAppointmentNumbers(result.dispatchNo());
f.db.update("INSERT INTO ll_order(id,customer_id,order_no,order_type) VALUES(9001,'101','LM-1','FIRST_LEG'),(9002,'101','LM-2','FIRST_LEG'),(9003,'202','LM-3','FIRST_LEG')");
f.db.update("INSERT INTO ll_order_detail VALUES(9001,'101','SHIP-1','ONT8'),(9001,'101','SHIP-1','ONT8'),(9001,'999','FOREIGN','ONT8'),(9001,'101','OTHER-WAREHOUSE','LGB8'),(9002,'101','SHIP-2','ONT8'),(9003,'202','SHIP-3','ONT8')");
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(11,9001,'101','ONT8'),(12,9002,'101',' ont8 '),(13,9003,'202','ONT8')");
f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,11,'101'),(1,12,'101'),(2,13,'202')");
f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,CONCAT('DISPATCH:',dispatch_order_id),customer_id,CONCAT('ORDER:',order_id),'FBA:ONT8',package_count FROM ll_dispatch_item");
var appointments=f.service.detail(f.actor,result.dispatchNo()).dispatchInfo().fbaAppointments();
assertThat(appointments).singleElement().satisfies(a->{
assertThat(a.appointmentNo()).isEqualTo("TEST-ISA-ONT8");
assertThat(a.shipmentNumbers()).containsExactly("SHIP-1","SHIP-2","SHIP-3");
});
assertThat(f.service.page(f.actor,query(Stage.BEFORE_LOADING,null)).records().get(0).fbaAppointments()).isEqualTo(appointments);
}
/** 换证后排除服务端冻结的旧主档引用,本车补传图保留,请求不能伪造文件来源来移除补传图。 */
@Test void savingAfterMasterReplacementKeepsOnlyTheNewMasterAndDispatchImages() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("driver-replacement",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
var drivers=(DriverService)ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);
when(driver.id()).thenReturn("71");when(driver.details()).thenReturn(details);when(driver.licenseFileId()).thenReturn("81");
when(details.region()).thenReturn("OVERSEAS");when(details.name()).thenReturn("主档司机");when(details.phone()).thenReturn("555-0100");
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(LocalDate.of(2099,1,1));
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
var input=f.configured("input",List.of());
var requestDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L)));
var first=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(created.recordVersion(),"first",input.vehicle(),requestDriver,input.plannedDepartureAt(),null,null,true,null)));
var snapshot=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver();var proof=snapshot.evidence();
when(driver.licenseFileId()).thenReturn("82");
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");clearInvocations(evidence);
doThrow(new IllegalArgumentException("旧主档证照不能按本车附件读取")).when(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
var changedDriver=new DispatchCommands.Driver(ResourceMode.MASTER,71L,snapshot.name(),snapshot.phone(),new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),91L));
f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(first.recordVersion(),"second",input.vehicle(),changedDriver,input.plannedDepartureAt(),null,null,true,null)));
var saved=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(saved.fileIds()).containsExactly(82L,91L);assertThat(saved.masterLicenseFileId()).isEqualTo(82L);
verify(evidence,never()).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(81L));
verify(evidence).metadata(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER),eq(91L));
}
/** 只读列表查询保持正式阶段与审核分区组合。 */
private static DispatchViews.Query query(Stage stage,BeforeLoadingSection section) {
return new DispatchViews.Query(1,20,null,null,null,null,null,StatusFilter.ACTIVE,stage,section,null);
}
}
@@ -29,7 +29,7 @@ class DispatchMaintenancePersistenceTest {
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var vehicle = vehicle(f, mode, List.of(301L));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装",
" fleet@example.test ", "厢式货车", " account ", " appointment ");
" fleet@example.test ", "厢式货车", " account ", List.of(new com.baiyue.lastleg.dispatch.port.DispatchContracts.DestinationAppointment("FBA:ONT8", " appointment ")));
var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(),
input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance);
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
@@ -73,7 +73,7 @@ class DispatchMaintenancePersistenceTest {
@Test void emptyMaintenanceCanBeSavedAndCleared() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var created = f.tx.execute(t -> f.service.create(f.actor, f.create("empty-maintenance", List.of())));
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"appointmentNo\":\"\"}", Maintenance.class);
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"destinationAppointments\":[]}", Maintenance.class);
assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty)));
@@ -91,7 +91,7 @@ class DispatchMaintenancePersistenceTest {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) {
for (String field : List.of("fleetEmail", "appointmentAccount")) {
int max = field.equals("fleetEmail") ? 254 : 128;
assertThat(json.treeToValue(json.createObjectNode().put(field, "a".repeat(max)), Maintenance.class)).isNotNull();
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "a".repeat(max + 1)), Maintenance.class))
@@ -110,7 +110,7 @@ class DispatchMaintenancePersistenceTest {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("projection-maintenance-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, "AP-LOCAL");
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, List.of());
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(),
"projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance)));
var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository,
@@ -147,7 +147,8 @@ class DispatchMaintenancePersistenceTest {
assertThat(fields.size()).isEqualTo(Maintenance.class.getRecordComponents().length);
assertThat(schema.path("required").isMissingNode()).isTrue();
for (var field : Maintenance.class.getRecordComponents()) {
assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue();
if(field.getName().equals("destinationAppointments")) assertThat(fields.path(field.getName()).path("type").asText()).isEqualTo("array");
else assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue();
for (var value : fields.path(field.getName()).path("enum")) {
assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull();
}
@@ -155,7 +156,8 @@ class DispatchMaintenancePersistenceTest {
assertThat(fields.path("plannedDeliveryDate").path("format").asText()).isEqualTo("date");
assertThat(fields.path("fleetEmail").path("maxLength").asInt()).isEqualTo(254);
assertThat(fields.path("appointmentAccount").path("maxLength").asInt()).isEqualTo(128);
assertThat(fields.path("appointmentNo").path("maxLength").asInt()).isEqualTo(128);
assertThat(fields.path("destinationAppointments").path("items").path("$ref").asText()).isEqualTo("#/components/schemas/DispatchDestinationAppointment");
assertThat(schemas.path("DispatchDestinationAppointment").path("properties").path("appointmentNo").path("maxLength").asInt()).isEqualTo(128);
}
/** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */
@@ -142,6 +142,7 @@ class DispatchReviewWorkflowPersistenceTest {
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
f.prepareAppointmentNumbers(prior.dispatchNo());
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
}
@@ -119,7 +119,7 @@ class DispatchWholeSourcePersistenceTest {
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(),"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_inbound_plan_line(id,order_id,customer_id) VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation(pallet_id,line_id) VALUES(?,?)",id,id);
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());
}
@@ -65,11 +65,10 @@ class AppointmentWorkDispatchBindingTest {
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释");
assertNotNull(service.reviewBlockReason("D-20",departure,null));
assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120)));
assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60)));
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
// 目的仓编号及时间由整车入口校验;此端口只核实真实来源分配。
assertNull(service.reviewBlockReason("D-20",departure,null));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("分配量"));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(batch.appointmentStatus()).thenReturn("PENDING");
assertDoesNotThrow(()->service.requireDeparture("D-20")); // The current receiving agreement, not the old header, authorizes receipt.
@@ -77,16 +76,16 @@ class AppointmentWorkDispatchBindingTest {
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(true);
clearInvocations(mapper);
assertTrue(service.departureBlockReason("D-20").contains("待仓库确认"));verify(mapper,never()).lockAppointment(anyLong());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertNull(service.departureBlockReason("D-20"));verify(mapper,never()).lockAppointment(anyLong());
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(false);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("OTHER",4));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertTrue(service.reviewEvidence("D-20").isEmpty());
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",3));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认"));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
@@ -133,7 +132,7 @@ class AppointmentWorkDispatchBindingTest {
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,one).sourceKey());
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,two).sourceKey());
}
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
@Test void prearrangementBuildsRealSourceRowsWithoutMandatoryAppointmentBatches() {
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
@@ -147,9 +146,10 @@ class AppointmentWorkDispatchBindingTest {
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
for(var status:List.of("SUCCESS","CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
assertDoesNotThrow(()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
}
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
verify(mapper,times(3)).insert(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","FBA:ONT8",4));
verify(changes,times(3)).requirementsChanged();
}
}
@@ -182,13 +182,22 @@ class AppointmentWorkDispatchPersistenceTest {
assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"));
}
/** 正式确认未完成时,候选和写入都阻止提前排车,不留下部分分配。 */
@Test void unconfirmedAppointmentBlocksBothCandidateAndBindingBeforeReview() {
/** 正式确认未完成也允许先排车;不自动带入尚未确认的预约引用。 */
@Test void unconfirmedAppointmentAllowsPrearrangementWithoutAutomaticConfirmedNumber() {
when(receiving.currentAgreement(batch,"101")).thenReturn(null);
when(receiving.snapshots(anyList())).thenReturn(List.of());
assertFalse(candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L)).blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
var candidate=candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L));
assertTrue(candidate.blockReasons().isEmpty());assertTrue(candidate.appointmentBatches().isEmpty());
assertDoesNotThrow(()->tx.executeWithoutResult(s->bind(11)));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
}
/** 先排后约沿本车真实来源记账,不能省略客户或箱数,也不占用尚未确认的预约批次。 */
@Test void fbaPrearrangementKeepsWholePhysicalSourcesWithoutBatchSelection() {
var physical=mapper.dispatchPhysicalById(1).stream().filter(p->p.itemId()==11).findFirst().orElseThrow();
tx.executeWithoutResult(s->{binding.lockBatches(Map.of());binding.bindItems(1,List.of(new PhysicalItem(physical.itemId(),physical.customerId(),physical.planPalletId(),physical.sourceStage(),physical.packages())),List.of());});
var rows=jdbc.queryForList("SELECT batch_key,customer_id,package_count FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=11");
assertEquals(1,rows.size());assertEquals("DISPATCH:1",rows.get(0).get("BATCH_KEY"));
assertEquals("101",rows.get(0).get("CUSTOMER_ID"));assertEquals(physical.packages(),rows.get(0).get("PACKAGE_COUNT"));
}
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
@@ -27,6 +27,19 @@ class VehicleApplicationServiceTest {
service = new VehicleApplicationService(repository, mock(VehicleAssignmentGuard.class));
}
/** 可用性排除集传入仓储查询,分页不先取再过滤。 */
@Test void availabilityIsAppliedBeforePaging() {
var assignments=mock(VehicleAssignmentGuard.class);
service=new VehicleApplicationService(repository,assignments);
when(assignments.unavailableForDispatch("PC-1")).thenReturn(java.util.List.of(12L));
when(repository.page(org.mockito.ArgumentMatchers.any())).thenReturn(new VehicleRepository.PageSlice(java.util.List.of(),1,20,0,0));
service.page(new VehicleModels.Query(1,20,null,null,"OVERSEAS","IN_SERVICE","PC-1"));
var criteria=org.mockito.ArgumentCaptor.forClass(VehicleRepository.SearchCriteria.class);
org.mockito.Mockito.verify(repository).page(criteria.capture());
assertThat(criteria.getValue().excludedIds()).containsExactly(12L);
assertThat(criteria.getValue().status()).isEqualTo("IN_SERVICE");
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
@@ -32,6 +32,10 @@ class ReturnRelabelPackingServiceTest {
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1");
var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B");
var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW");
var physical = mock(ReturnInventoryCartonItemMapper.class);
var stock = new ReturnInventoryCartonItem(); stock.setSkuCode("SKU"); stock.setQuantity(6L);
when(physical.lockCarton(anyLong())).thenReturn(List.of(stock));
ReflectionTestUtils.setField(service, "cartonItems", physical);
var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "histories", history);
if (inheritedCompletion) {
@@ -42,7 +46,6 @@ class ReturnRelabelPackingServiceTest {
var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1);
when(facts.selectList(any())).thenReturn(List.of(fact));
ReflectionTestUtils.setField(service, "facts", facts);
var physical = mock(ReturnInventoryCartonItemMapper.class);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L);
when(physical.lockCarton(20L)).thenReturn(List.of(line));
ReflectionTestUtils.setField(service, "cartonItems", physical);
@@ -57,13 +60,15 @@ class ReturnRelabelPackingServiceTest {
var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1);
assertEquals(List.of("A", "B"), request.sourceCartonMarks());
assertNull(request.targetCartonMark());
assertEquals(List.of(new ReturnInventoryRequests.SourceQuantity("A", "SKU", 6),
new ReturnInventoryRequests.SourceQuantity("B", "SKU", 6)), request.expectedSourceQuantities());
assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3),
new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions());
return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null);
});
var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second),
new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null,
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE));
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE, null));
assertEquals("NEW", result.cartonMark());
assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId());
assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark());
@@ -87,9 +92,9 @@ class ReturnRelabelPackingServiceTest {
var app = new RelabelApplication();
var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER");
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong")));
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong", null, null, null, null, null, null)));
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest")));
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest", null, null, null, null, null, null)));
verifyNoInteractions(inventory);
}
private RelabelItem item(long id, long cartonId, String mark) {
@@ -647,13 +647,59 @@ class ReturnInventoryServiceTest {
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5)));
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing",
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null);
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null,
List.of(new ReturnInventoryRequests.SourceQuantity("RC-SOURCE", "SKU-A", 5)));
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request));
assertThat(error.getReason()).contains("独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void freshKeyCannotRepeatPartialMergeFromAStaleSourceSnapshot() {
var source = prepareConcurrentSource(); source.setTotalQuantity(10L);
var line = item(41L, "SKU-A", 10L);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var saved = new java.util.HashMap<String, ReturnInventoryOperation>();
org.mockito.Mockito.doAnswer(call -> {
ReturnInventoryOperation operation = call.getArgument(0); saved.put(operation.getIdempotencyKey(), operation); return 1;
}).when(operations).insert(any(ReturnInventoryOperation.class));
when(operations.lockKey(anyString(), anyString())).thenAnswer(call -> saved.get(call.getArgument(1)));
var identity = new PdaWarehouseIdentity("worker", "作业员", 42L);
var first = boxMerge("first-click", 10L);
service.mergeBoxes(identity, first);
assertThat(line.getQuantity()).isEqualTo(7L);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(identity, boxMerge("next-click", 10L)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
assertThat(error.getReason()).contains("库存已变化");
assertThat(line.getQuantity()).isEqualTo(7L);
verify(cartons, org.mockito.Mockito.times(1)).insert(any(ReturnInventoryCarton.class));
verify(operations, org.mockito.Mockito.times(1)).insert(any(ReturnInventoryOperation.class));
service.mergeBoxes(identity, boxMerge("rescanned", 7L));
assertThat(line.getQuantity()).isEqualTo(4L);
verify(cartons, org.mockito.Mockito.times(2)).insert(any(ReturnInventoryCarton.class));
}
@Test
void boxMergeRejectsMissingSnapshotBeforeInventoryAccess() {
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), null, "missing", null,
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, null);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("worker", "作业员", 42L), request));
assertThat(error.getStatusCode().value()).isEqualTo(400);
verifyNoInteractions(cartons, cartonItems, balances);
}
/** 创建同一实物扣三件的请求,快照与请求键按场景独立变化。 */
private ReturnInventoryRequests.BoxMerge boxMerge(String key, long expectedQuantity) {
return new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), null, key,
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 3L)),
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE,
List.of(new ReturnInventoryRequests.SourceQuantity("RC-SOURCE", "SKU-A", expectedQuantity)));
}
@Test
void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
@@ -60,7 +60,7 @@ class ReturnReceiptServiceTest {
for (String code : List.of("中文包裹", "PK-中文")) {
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null,
List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code,
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true, 0L);
assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason())
.contains("实物编码无法生成条码");
}
@@ -435,7 +435,7 @@ class ReturnReceiptServiceTest {
ReturnOrderItem second = item(12L, "SKU-B", 2, 0);
stubNewReceipt(order, List.of(first, second), "RC-1");
var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
var result = service.confirm(identity, confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", 1,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1))));
assertFalse(result.fullyMatched());
@@ -464,7 +464,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
stubNewReceipt(order, List.of(line), "RC-AUTO");
var request = confirm("SKU", null, "RC-AUTO", "key-final",
var request = confirm("SKU", null, "RC-AUTO", "key-final", 1,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var first = service.confirm(identity, request);
@@ -489,7 +489,7 @@ class ReturnReceiptServiceTest {
stubNewReceipt(order, List.of(line), "SP-SOURCE");
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key",
"PARCEL", null, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
"PARCEL", null, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true, 0L);
var result = service.confirm(identity, request);
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
@@ -514,7 +514,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(line));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-over", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2)))));
confirm("RETURN_NO", null, null, "key-over", 1, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2)))));
assertEquals(409, error.getStatusCode().value());
verify(receipts, never()).insert(any(ReturnReceipt.class));
@@ -527,7 +527,7 @@ class ReturnReceiptServiceTest {
when(orders.lockNo("RT-1")).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "key-cross", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
confirm("RETURN_NO", null, null, "key-cross", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(404, error.getStatusCode().value());
verify(orderItems, never()).lockOrder(any());
@@ -539,7 +539,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 2, 0);
ReturnOrderItem line = item(11L, "SKU-A", 2, 0);
stubNewReceipt(order, List.of(line), "RC-1");
var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1",
var original = confirm("RETURN_NO", "TRACK-1", "RC-1", "key-1", 0,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
service.confirm(identity, original);
ArgumentCaptor<ReturnReceipt> savedReceipt = ArgumentCaptor.forClass(ReturnReceipt.class);
@@ -547,7 +547,7 @@ class ReturnReceiptServiceTest {
when(receipts.lockKey("operator-1", "key-1")).thenReturn(savedReceipt.getValue());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1",
confirm("RETURN_NO", "TRACK-CHANGED", "RC-1", "key-1", 0,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
@@ -555,6 +555,29 @@ class ReturnReceiptServiceTest {
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
}
@Test
void freshKeyAfterLostResponseCannotCountTheSameSnapshotTwice() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 10, 0);
ReturnOrderItem line = item(11L, "SKU-A", 10, 0);
stubNewReceipt(order, List.of(line), "RC-1");
var items = List.of(new PdaReturnReceiptRequests.Item("SKU-A", 2));
service.confirm(identity, confirm("SKU", null, null, "first-click", 0, items));
var stale = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("SKU", null, null, "new-click", 0, items)));
assertEquals(409, stale.getStatusCode().value());
assertThat(stale.getReason()).contains("实收数量已变化");
assertEquals(2, line.getReceivedQuantity());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
var next = service.confirm(identity, confirm("SKU", null, null, "after-refresh", 2, items));
assertEquals(4, next.receivedQuantity());
verify(inventory, times(2)).createReceiptCarton(any(), any(), any(), any());
}
private void stubNewReceipt(ReturnOrder order, List<ReturnOrderItem> items, String cartonMark) {
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(items);
@@ -568,7 +591,7 @@ class ReturnReceiptServiceTest {
@Test
void sameKeyCommittedWhileWaitingForOrderLockReplaysCompletedReceipt() throws Exception {
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var request = confirm("RETURN_NO", null, null, "concurrent-key", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
ReturnReceipt committed = new ReturnReceipt();
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED");
@@ -597,7 +620,7 @@ class ReturnReceiptServiceTest {
ReturnReceipt committed = new ReturnReceipt(); committed.setRequestHash("different-content");
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
var error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
confirm("RETURN_NO", null, null, "concurrent-key", 0, List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
assertEquals(409, error.getStatusCode().value());
verifyNoInteractions(inventory, batches, orderItems);
}
@@ -640,13 +663,12 @@ class ReturnReceiptServiceTest {
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
String key, List<PdaReturnReceiptRequests.Item> items) {
String key, long expectedReceivedQuantity, List<PdaReturnReceiptRequests.Item> items) {
List<PdaReturnReceiptRequests.Item> boxed = items.stream()
.map(item -> new PdaReturnReceiptRequests.Item(item.skuCode(), item.receivedQuantity(), 1L)).toList();
// These focused tests exercise receipt/idempotency behavior at the
// carton level. Pallet receipts have a separate multi-carton contract.
// 本组测试验证单箱签收和幂等行为;托盘多箱由独立合同覆盖。
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key,
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true, expectedReceivedQuantity);
}
}