fix: complete October 22 workflow feedback and request failure recovery
This commit is contained in:
+62
@@ -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();}
|
||||
}
|
||||
+27
-9
@@ -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);
|
||||
|
||||
+84
-5
@@ -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) {
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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));}
|
||||
}
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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);
|
||||
}
|
||||
|
||||
/** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */
|
||||
|
||||
+1
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
|
||||
+14
-14
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -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() {
|
||||
|
||||
+13
@@ -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)));
|
||||
|
||||
+9
-4
@@ -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) {
|
||||
|
||||
+47
-1
@@ -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");
|
||||
|
||||
+36
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user