修复费用审核前调整和运输取消及对账明细约束
This commit is contained in:
+86
-4
@@ -2,20 +2,38 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.transport.TransportTaskModels;
|
||||
import com.baiyue.lastleg.dispatch.transport.TransportTaskProjection;
|
||||
import com.baiyue.lastleg.dispatch.transport.TransportTaskRepository;
|
||||
import com.baiyue.lastleg.dispatch.transport.TransportTaskService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
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 DispatchReviewWorkflowPersistenceTest {
|
||||
/** 本土卡车不建立仓库整车绑定,仍须完成同轮调度及理货审核。 */
|
||||
@Test void localTruckReviewsUseTransportTripWithoutWarehouseBinding() throws Exception {
|
||||
/** 本土卡车托数和箱数独立,真实托量不依赖箱数或仓库货物明细即可进入同轮两级审核。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,0", "0,6", "5,6"})
|
||||
void localTruckReviewsUseTransportTripWithoutWarehouseBinding(int pallets,int packages) throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("truck-review",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",created.dispatchNo());
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("truck-review",List.of())));
|
||||
// 初次建单与取消后补建均从原单保存真实摘要,不生成仓内 dispatch_item。
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK',item_count=1,pallet_count=?,package_count=? WHERE dispatch_no=?",pallets,packages,created.dispatchNo());
|
||||
assertThat(f.repository.cargo(f.repository.find(created.dispatchNo()).getId())).isEmpty();
|
||||
var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class);
|
||||
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]");
|
||||
org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip);
|
||||
@@ -32,6 +50,70 @@ class DispatchReviewWorkflowPersistenceTest {
|
||||
org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(3)).verifyLocalTruckReview(created.dispatchNo());
|
||||
org.mockito.Mockito.verifyNoInteractions(fleet,master);
|
||||
}
|
||||
|
||||
/** 取消投影释放原单后重新登记五托零箱,旧单保留取消历史,新单可独立完成两级审核。 */
|
||||
@Test void cancelledLocalTruckCanRebuildAndReviewPalletOnlyCargo() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var links=mock(TransportTaskRepository.class);
|
||||
var trucks=mock(UsTruckOrderRepository.class);
|
||||
var numbers=mock(DispatchNumberGenerator.class);
|
||||
when(numbers.next()).thenReturn("PC-TRUCK-OLD","PC-TRUCK-NEW");
|
||||
var projection=new TransportTaskProjection(f.repository,links,trucks,numbers,f.json);
|
||||
var trip=new FleetTripEntity();
|
||||
trip.setId(701L);trip.setTripNo("TRIP-OLD");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("PLANNED");
|
||||
trip.setWarehouseId(9L);trip.setWarehouseCode("WH9");trip.setWarehouseName("测试起运仓");
|
||||
trip.setRecordVersion(0L);trip.setRequestHash("a".repeat(64));trip.setStopsJson("[]");
|
||||
var old=f.tx.execute(s->projection.register(trip,1,5,0,f.actor.operatorId()));
|
||||
when(links.byTrip(701L)).thenAnswer(call->f.repository.find(old.getDispatchNo()));
|
||||
when(links.truck(old.getDispatchNo())).thenReturn(new TransportTaskModels.TruckLink(old.getDispatchNo(),"TRIP-OLD","101","TR-TEST"));
|
||||
var order=new UsTruckOrder();order.setId(801L);order.setCustomerId("101");order.setOrderNo("TR-TEST");
|
||||
order.setStatus("DISPATCHED");order.setVersion(0L);order.setPalletCount(5);order.setPackageCount(0);
|
||||
when(trucks.lockNo("101","TR-TEST")).thenReturn(order);
|
||||
|
||||
trip.setStatus("CANCELLED");trip.setRecordVersion(1L);trip.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
f.tx.executeWithoutResult(s->projection.changed(new TransportTripChanged(trip,"CANCEL","重新排车",f.actor.operatorId())));
|
||||
assertThat(f.repository.find(old.getDispatchNo()).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");
|
||||
verify(links).cancel(old.getDispatchNo());
|
||||
|
||||
trip.setId(702L);trip.setTripNo("TRIP-NEW");trip.setStatus("PLANNED");trip.setRecordVersion(0L);
|
||||
var rebuilt=f.tx.execute(s->projection.register(trip,1,order.getPalletCount(),order.getPackageCount(),f.actor.operatorId()));
|
||||
f.tx.executeWithoutResult(s->projection.transition("101","TR-TEST","DISPATCHED","TRANSPORT_ASSIGN",f.actor.operatorId(),"补建排车单","rebuild"));
|
||||
assertThat(rebuilt.getDispatchNo()).isNotEqualTo(old.getDispatchNo());
|
||||
assertThat(f.repository.cargo(rebuilt.getId())).isEmpty();
|
||||
var transport=mock(TransportTaskService.class);
|
||||
when(transport.transport(eq(rebuilt.getDispatchNo()),anyBoolean())).thenReturn(trip);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport);
|
||||
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,rebuilt.getDispatchNo(),new DispatchCommands.SubmitReview(rebuilt.getRecordVersion(),"rebuild-submit",null)));
|
||||
var dispatched=review(f,f.actor,submitted,false,"rebuild-dispatch",Decision.APPROVED,null);
|
||||
var loaded=review(f,f.actor,dispatched,true,"rebuild-loading",Decision.APPROVED,null);
|
||||
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
|
||||
assertThat(f.repository.find(rebuilt.getDispatchNo()).getReviewState()).isEqualTo("APPROVED");
|
||||
assertThat(f.repository.find(old.getDispatchNo()).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(order.getStatus()).isEqualTo("DISPATCHED");
|
||||
verify(transport,times(3)).verifyLocalTruckReview(rebuilt.getDispatchNo());
|
||||
}
|
||||
|
||||
/** 本土卡车仍拒绝零托零箱;仓库派送不能只靠摘要托数或箱数绕过真实货物检查。 */
|
||||
@ParameterizedTest
|
||||
@CsvSource({"LOCAL_TRUCK,0,0", "DELIVERY,0,0", "DELIVERY,5,0", "DELIVERY,5,6"})
|
||||
void emptyDispatchCannotSubmitAndDoesNotWriteReviewState(String taskType,int pallets,int packages) throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("empty-review",List.of())));
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type=?,pallet_count=?,package_count=? WHERE dispatch_no=?",taskType,pallets,packages,created.dispatchNo());
|
||||
if("LOCAL_TRUCK".equals(taskType))org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",
|
||||
org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class));
|
||||
|
||||
assertThatThrownBy(()->submit(f,created,"empty-submit"))
|
||||
.isInstanceOf(BizException.class).hasMessage("空排车不能提交审核");
|
||||
|
||||
var row=f.repository.find(created.dispatchNo());
|
||||
assertThat(row.getReviewState()).isEqualTo("DRAFT");
|
||||
assertThat(row.getReviewRound()).isZero();
|
||||
assertThat(row.getRecordVersion()).isEqualTo(created.recordVersion());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='SUBMIT_REVIEW'",Long.class)).isZero();
|
||||
}
|
||||
@Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3)))));
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.transport.*;
|
||||
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.pickup.FleetPickupRepository;
|
||||
import com.baiyue.lastleg.fleet.pickup.FleetPickupService;
|
||||
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||
import com.baiyue.lastleg.trucking.mapper.*;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 从真实本土卡车建单进入列表取消,覆盖两套版本及事务内资源、来源和命令账本。 */
|
||||
class DispatchTransportCancellationPersistenceTest {
|
||||
/** 未配车和已配车均取消原运输任务;原请求重试只恢复一次成功结果。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans={false,true})
|
||||
void cancellationReleasesTransportAndSourceWithoutWarehouseBinding(boolean assigned) throws Exception {
|
||||
var e=new Fixture();var number=e.create();
|
||||
if(assigned)e.assign(number);
|
||||
// 审核写主单版本,车次版本不会同步递增,不能把公开版本当成车次版本。
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET record_version=7 WHERE dispatch_no=?",number);
|
||||
var request=new DispatchCommands.ReasonCommand(7L,"cancel-truck-original","取消本次运输安排");
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isZero();
|
||||
var result=e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,request));
|
||||
assertThat(result.status()).isEqualTo(Status.CANCELLED);
|
||||
assertThat(result.recordVersion()).isEqualTo(8L);
|
||||
assertThat(e.status(number)).isEqualTo("CANCELLED");
|
||||
assertThat(e.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED");
|
||||
assertThat(e.f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("PENDING_DISPATCH");
|
||||
assertThat(e.f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNull();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_us_truck_order_status_history WHERE to_status='PENDING_DISPATCH'",Long.class)).isOne();
|
||||
assertThat(e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,request)).replayed()).isTrue();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isOne();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='CANCEL'",Long.class)).isOne();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='CANCEL'",Long.class)).isOne();
|
||||
assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,
|
||||
new DispatchCommands.ReasonCommand(7L,"cancel-truck-original","同键改原因")))).hasMessageContaining("原请求标识不能更换");
|
||||
assertThat(e.f.tx.execute(t->e.tasks.createTruck(e.f.actor.operatorId(),e.request("truck-create-again"))).dispatchNo()).isNotEqualTo(number);
|
||||
}
|
||||
|
||||
/** 公开旧版本和空原因均不得释放运输资源或写入成功账本。 */
|
||||
@Test void staleVersionAndMissingReasonCannotCancel() throws Exception {
|
||||
var e=new Fixture();var number=e.create();e.assign(number);
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET record_version=7 WHERE dispatch_no=?",number);
|
||||
assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,
|
||||
new DispatchCommands.ReasonCommand(0L,"cancel-stale","取消")))).hasMessageContaining("版本");
|
||||
assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,
|
||||
new DispatchCommands.ReasonCommand(7L,"cancel-blank"," ")))).hasMessageContaining("原因");
|
||||
e.assertUnchanged(number);
|
||||
}
|
||||
|
||||
/** 理货通过和装车后的任务仍禁止取消,不因修复分流放松执行门禁。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"APPROVED","LOADING","DELIVERING"})
|
||||
void executingTransportCannotCancel(String state) throws Exception {
|
||||
var e=new Fixture();var number=e.create();e.assign(number);
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET stage=?,review_state=? WHERE dispatch_no=?",
|
||||
"APPROVED".equals(state)?"BEFORE_LOADING":state,"APPROVED".equals(state)?state:"DRAFT",number);
|
||||
assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,
|
||||
new DispatchCommands.ReasonCommand(0L,"cancel-executing","取消")))).hasMessageContaining("排车");
|
||||
e.assertUnchanged(number);
|
||||
}
|
||||
|
||||
/** 原单事实不允许回退时,已释放资源及车次、主单变更必须整笔回滚。 */
|
||||
@Test void sourceConflictRollsBackTransportAndResourceRelease() throws Exception {
|
||||
var e=new Fixture();var number=e.create();e.assign(number);
|
||||
e.f.db.update("UPDATE ll_us_truck_order SET status='IN_TRANSIT'");
|
||||
assertThatThrownBy(()->e.f.tx.execute(t->e.f.service.cancel(e.f.actor,number,
|
||||
new DispatchCommands.ReasonCommand(0L,"cancel-source-conflict","取消")))).hasMessageContaining("原单状态");
|
||||
assertThat(e.status(number)).isEqualTo("ACTIVE");
|
||||
assertThat(e.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
assertThat(e.f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("IN_TRANSIT");
|
||||
assertThat(e.f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNotNull();
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
|
||||
assertThat(e.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 复用真实排车持久化夹具,补齐运输关联与本土卡车持久化,外部主档仅作受控提供者。 */
|
||||
private static final class Fixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final TransportTaskService tasks;
|
||||
|
||||
/** 接通真实建单、车次取消和同步投影,不用返回成功的取消替身。 */
|
||||
Fixture() throws Exception {
|
||||
var config=f.session.getConfiguration();
|
||||
config.addMapper(TransportTaskMapper.class);config.addMapper(UsTruckOrderMapper.class);config.addMapper(UsTruckOrderStatusHistoryMapper.class);
|
||||
table(UsTruckOrder.class);table(UsTruckOrderStatusHistory.class);
|
||||
f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD trip_no VARCHAR(64)");
|
||||
f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD order_no VARCHAR(64)");
|
||||
f.db.execute("ALTER TABLE ll_dispatch_truck_task ADD active_order VARCHAR(128) UNIQUE");
|
||||
f.db.execute("CREATE TABLE ll_dispatch_transport_create(trip_no VARCHAR(64) PRIMARY KEY,request_hash VARCHAR(64))");
|
||||
f.db.execute("CREATE TABLE ll_dispatch_transport_command(dispatch_no VARCHAR(64),request_key VARCHAR(128),request_hash VARCHAR(64),evidence_json CLOB,created_at TIMESTAMP)");
|
||||
var links=new TransportTaskRepository(f.session.getMapper(TransportTaskMapper.class));
|
||||
var trucks=new UsTruckOrderRepository(f.session.getMapper(UsTruckOrderMapper.class),mock(UsTruckOrderPalletMapper.class),
|
||||
f.session.getMapper(UsTruckOrderStatusHistoryMapper.class),mock(UsTruckOrderOperationMapper.class));
|
||||
var numbers=mock(DispatchNumberGenerator.class);var next=new java.util.concurrent.atomic.AtomicInteger();
|
||||
when(numbers.next()).thenAnswer(call->"PC-TRUCK-"+next.incrementAndGet());
|
||||
var projection=new TransportTaskProjection(f.repository,links,trucks,numbers,f.json);
|
||||
ReflectionTestUtils.setField(f.nativeFleetService,"transportEvents",(ApplicationEventPublisher)event->projection.changed((TransportTripChanged)event));
|
||||
ReflectionTestUtils.setField(f.nativeFleetService,"drivers",mock(DriverService.class,RETURNS_DEEP_STUBS));
|
||||
var trips=new FleetTripRepository(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetResourceMapper.class),
|
||||
f.session.getMapper(FleetTripHistoryMapper.class),f.session.getMapper(FleetTripDispatchMapper.class));
|
||||
tasks=new TransportTaskService(f.repository,links,projection,trips,f.nativeFleetService,mock(FleetPickupService.class),
|
||||
mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),
|
||||
mock(FleetTransportVehicleGuard.class),mock(TripOperationsEvidenceGateway.class),f.json);
|
||||
ReflectionTestUtils.setField(tasks,"reviewPhotos",mock(TransportReviewPhotos.class));
|
||||
ReflectionTestUtils.setField(f.service,"transportTasks",tasks);
|
||||
var order=new UsTruckOrder();order.setId(91L);order.setCustomerId("C1");order.setOrderNo("US-TRUCK-1");
|
||||
order.setOrigin("洛杉矶");order.setOriginAddress("起运地址");order.setDestination("安大略");order.setDestinationAddress("目的地址");
|
||||
order.setStatus("PENDING_DISPATCH");order.setVersion(0L);order.setPackageCount(22);order.setPalletCount(2);
|
||||
order.setCreatedAt(LocalDateTime.now());order.setUpdatedAt(order.getCreatedAt());trucks.insertOrder(order);
|
||||
}
|
||||
|
||||
/** 按实体字段建立隔离内存表,SQL通过真实Mapper运行。 */
|
||||
private void table(Class<?> type) {
|
||||
var info=TableInfoHelper.getTableInfo(type);var columns=new ArrayList<String>();columns.add(info.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:info.getFieldList()) {
|
||||
var javaType=field.getPropertyType();String sqlType=javaType==LocalDateTime.class?"TIMESTAMP(3)":
|
||||
javaType==BigDecimal.class?"DECIMAL(18,6)":Number.class.isAssignableFrom(javaType)?"BIGINT":"VARCHAR(4000)";
|
||||
columns.add(field.getColumn()+" "+sqlType);
|
||||
}
|
||||
f.db.execute("CREATE TABLE "+info.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
|
||||
/** 从本土卡车正式安排入口创建排车。 */
|
||||
String create() { return f.tx.execute(t->tasks.createTruck(f.actor.operatorId(),request("truck-create-original"))).dispatchNo(); }
|
||||
|
||||
/** 每次安排使用新请求键,来源保留同一客户和业务单号。 */
|
||||
TransportTaskModels.TruckCreate request(String key) {
|
||||
var departure=Instant.now().plusSeconds(3600);
|
||||
return new TransportTaskModels.TruckCreate("C1","US-TRUCK-1",9L,departure,departure.plusSeconds(3600),key);
|
||||
}
|
||||
|
||||
/** 模拟已持有本车资源的事实,取消不能删除车辆或司机主档。 */
|
||||
void assign(String number) {
|
||||
f.db.update("UPDATE ll_fleet_trip SET vehicle_id=71,driver_id=81 WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",number);
|
||||
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',71,id,trip_no FROM ll_fleet_trip");
|
||||
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',81,id,trip_no FROM ll_fleet_trip");
|
||||
}
|
||||
|
||||
/** 直接核对主单持久化状态。 */
|
||||
String status(String number) { return f.db.queryForObject("SELECT status FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number); }
|
||||
|
||||
/** 失败命令不能改变来源安排、资源占用及成功账本。 */
|
||||
void assertUnchanged(String number) {
|
||||
assertThat(status(number)).isEqualTo("ACTIVE");
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_us_truck_order",String.class)).isEqualTo("DISPATCHED");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
|
||||
assertThat(f.db.queryForObject("SELECT active_order FROM ll_dispatch_truck_task",String.class)).isNotNull();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -511,9 +511,11 @@ class OverseasManualFeeServiceTest {
|
||||
verify(repository).insertFunding(state.capture());
|
||||
funding = state.getValue();
|
||||
when(repository.lockFunding(701L)).thenReturn(funding);
|
||||
assertThat(service.detail(701L).getAllowedActions()).contains("ADJUST", "VOID");
|
||||
funding.setWalletStatus("FROZEN"); funding.setWalletHoldId("9001");
|
||||
var reviewed = service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "purchase-review"));
|
||||
assertThat(reviewed.getWalletStatus()).isEqualTo("SETTLE_PENDING");
|
||||
assertThat(reviewed.getAllowedActions()).doesNotContain("ADJUST", "VOID");
|
||||
assertThat(reviewed.getWalletSettlement()).isNull();
|
||||
verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), eq(new BigDecimal("88.00")), anyString(), eq(1L));
|
||||
verify(wallet, times(1)).publish(eq("SETTLE"), eq(10001L), anyString(), anyString(), isNull(), anyString(), eq(2L));
|
||||
@@ -553,4 +555,115 @@ class OverseasManualFeeServiceTest {
|
||||
result, holdStatus, new BigDecimal(amount), "FAILED".equals(result) ? "INSUFFICIENT" : null,
|
||||
"FAILED".equals(result) ? "余额不足" : null, Instant.parse("2026-10-22T00:00:00Z"), "9001", "9002");
|
||||
}
|
||||
/** 人工费用审核前可调整、作废,审核通过后不论结算方式均关闭这两个入口。 */
|
||||
@Test void manualActionsAreAvailableOnlyBeforeApproval() {
|
||||
for (String mode : List.of("WALLET", "BILL")) {
|
||||
funding.setCollectionMode(mode); funding.setWalletStatus("BILL".equals(mode) ? "NOT_REQUIRED" : "FROZEN");
|
||||
fee.setReviewStatus("PENDING"); fee.setPublicationStatus("UNPUBLISHED");
|
||||
assertThat(service.detail(701L).getAllowedActions()).contains("ADJUST", "VOID");
|
||||
fee.setReviewStatus("APPROVED");
|
||||
assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID");
|
||||
assertThatThrownBy(() -> service.adjust(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest(1,
|
||||
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"), "approved-adjust"))).hasMessageContaining("审核前");
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复费用", "approved-void"))).hasMessageContaining("审核前");
|
||||
}
|
||||
verifyNoInteractions(wallet, outbox);
|
||||
verify(repository, never()).updateFee(any());
|
||||
}
|
||||
|
||||
/** 审核前作废释放真实冻结,重复请求不重复发出释放命令。 */
|
||||
@Test void voidBeforeReviewReleasesOnceAndPreservesReason() {
|
||||
var command = new java.util.concurrent.atomic.AtomicReference<OverseasFeeCommand>();
|
||||
when(repository.command("operator", "MANUAL_VOID", "void-before")).thenAnswer(invocation -> command.get());
|
||||
doAnswer(invocation -> { command.set(invocation.getArgument(0)); return null; }).when(repository).insertCommand(any());
|
||||
var request = new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复录费", "void-before");
|
||||
service.voidSource(701L, "operator", request);
|
||||
service.voidSource(701L, "operator", request);
|
||||
assertThat(fee.getSourceStatus()).isEqualTo("VOIDED");
|
||||
assertThat(fee.getReviewReason()).isEqualTo("重复录费");
|
||||
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING");
|
||||
verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L));
|
||||
verifyNoInteractions(outbox);
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "变更原因", "void-before")))
|
||||
.hasMessageContaining("其他请求");
|
||||
}
|
||||
|
||||
/** 未发布账单作废无需请求 Finance;过期版本必须先拒绝。 */
|
||||
@Test void voidUnpublishedBillChecksVersionWithoutPublishingCancellation() {
|
||||
funding.setCollectionMode("BILL"); funding.setWalletStatus("NOT_REQUIRED");
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(2, "重复", "stale-void")))
|
||||
.hasMessageContaining("审核前");
|
||||
verify(repository, never()).updateFee(any());
|
||||
service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "bill-void"));
|
||||
assertThat(fee.getSourceStatus()).isEqualTo("VOIDED");
|
||||
verifyNoInteractions(wallet, outbox);
|
||||
}
|
||||
|
||||
/** 已发布或资金/作废流程在途时不能通过伪造审核状态开放修改。 */
|
||||
@Test void pendingReviewStillRequiresUnpublishedAndSafeFundingState() {
|
||||
fee.setPublicationStatus("PUBLISHED");
|
||||
assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID");
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "published-void")))
|
||||
.hasMessageContaining("审核前");
|
||||
fee.setPublicationStatus("UNPUBLISHED");
|
||||
for (String status : List.of("SETTLE_PENDING", "SETTLED", "RELEASE_PENDING")) {
|
||||
funding.setWalletStatus(status);
|
||||
assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID");
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "funding-void")))
|
||||
.hasMessageContaining("审核前");
|
||||
}
|
||||
funding.setWalletStatus("FROZEN"); fee.setVoidStatus("PENDING_FINANCE");
|
||||
assertThat(service.detail(701L).getAllowedActions()).doesNotContain("ADJUST", "VOID");
|
||||
assertThatThrownBy(() -> service.voidSource(701L, "operator", new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest(1, "重复", "pending-void")))
|
||||
.hasMessageContaining("审核前");
|
||||
verifyNoInteractions(wallet, outbox);
|
||||
verify(repository, never()).updateFee(any());
|
||||
}
|
||||
|
||||
/** 调整使用独立资金身份,原冻结回执继续核对旧金额,新费用只锁定一次。 */
|
||||
@Test void adjustmentReplacesPendingFeeWithIndependentFundingAndReplaysOnce() {
|
||||
var request = prepareAdjustedFee();
|
||||
var command = new java.util.concurrent.atomic.AtomicReference<OverseasFeeCommand>();
|
||||
when(repository.command("operator", "MANUAL_ADJUST", "adjust-before")).thenAnswer(invocation -> command.get());
|
||||
doAnswer(invocation -> { command.set(invocation.getArgument(0)); return null; }).when(repository).insertCommand(any());
|
||||
var first = service.adjust(701L, "operator", request);
|
||||
var replay = service.adjust(701L, "operator", request);
|
||||
assertThat(first.getSourceFeeId()).isEqualTo(801L);
|
||||
assertThat(replay.getSourceFeeId()).isEqualTo(801L);
|
||||
assertThat(first.getReviewStatus()).isEqualTo("PENDING");
|
||||
assertThat(first.getWalletStatus()).isEqualTo("FREEZE_PENDING");
|
||||
assertThat(fee.getSourceStatus()).isEqualTo("SUPERSEDED");
|
||||
assertThat(fee.getAmount()).isEqualByComparingTo("70.00");
|
||||
verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L));
|
||||
verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), eq("801-1"), eq(new BigDecimal("88.00")), anyString(), eq(1L));
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
/** 原锁款尚在途时也能调整,迟到的原成功回执只能释放原冻结,不能收费。 */
|
||||
@Test void supersededFeeReleasesItsLateFreezeResult() {
|
||||
var request = prepareAdjustedFee();
|
||||
funding.setWalletStatus("FREEZE_PENDING");
|
||||
service.adjust(701L, "operator", request);
|
||||
service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "70.00"));
|
||||
assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING");
|
||||
assertThat(fee.getWalletSettlement()).isNull();
|
||||
verify(wallet, times(1)).publish(eq("RELEASE"), eq(10001L), anyString(), eq("701-1"), isNull(), anyString(), eq(2L));
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
/** 构造同业务调整与独立新费用;模拟仓储层按费用身份保存资金状态。 */
|
||||
private com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest prepareAdjustedFee() {
|
||||
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
|
||||
fee.setSourceType(request.sourceType()); fee.setBusinessType(request.businessType());
|
||||
fee.setBusinessId(request.businessId()); fee.setBusinessNo(request.businessNo());
|
||||
fee.setFeeItemCode(request.feeItemCode()); fee.setAmount(new BigDecimal("70.00")); fee.setOriginalAmount(new BigDecimal("70.00"));
|
||||
var next = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(fee, next);
|
||||
next.setId(101L); next.setSourceFeeId(801L); next.setAmount(request.amount()); next.setOriginalAmount(request.amount());
|
||||
when(sources.create(eq("operator"), startsWith("manual-adjust:"), eq(request))).thenReturn(next);
|
||||
when(repository.lockFee(801L)).thenReturn(next);
|
||||
var nextFunding = new java.util.concurrent.atomic.AtomicReference<OverseasFeeFunding>();
|
||||
when(repository.lockFunding(801L)).thenAnswer(invocation -> nextFunding.get());
|
||||
doAnswer(invocation -> { nextFunding.set(invocation.getArgument(0)); return null; }).when(repository).insertFunding(any());
|
||||
return new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest(1, request, "adjust-before");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -59,7 +59,7 @@ class ProcurementWorkflowPersistenceTest {
|
||||
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
|
||||
var request=new Rejection(2,"reject","照片不清晰");
|
||||
var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
|
||||
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isFalse();
|
||||
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue();
|
||||
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
|
||||
long receipt=rejected.receipts().get(0).id();
|
||||
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
|
||||
@@ -67,6 +67,9 @@ class ProcurementWorkflowPersistenceTest {
|
||||
file(105L,11L,"9","image/png");
|
||||
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
|
||||
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
|
||||
assertThat(current.rejectionReason()).isNull();
|
||||
assertThat(orders.findNo("9","CG-1").getCustomerRejectionReason()).isNull();
|
||||
assertThat(tx.execute(t->service.read("9","CG-1")).rejectionReason()).isNull();
|
||||
var resubmitted=tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
|
||||
assertThat(resubmitted.rejectionReason()).isNull();
|
||||
assertThat(resubmitted.photosNeedUpdate()).isFalse();
|
||||
@@ -106,11 +109,12 @@ class ProcurementWorkflowPersistenceTest {
|
||||
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "multi-r2", 6, List.of(104L))));
|
||||
tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "multi-ask")));
|
||||
var rejected = tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "multi-reject", "两批照片均不清晰")));
|
||||
assertThat(rejected.photosNeedUpdate()).isFalse();
|
||||
assertThat(rejected.photosNeedUpdate()).isTrue();
|
||||
long first = rejected.receipts().get(0).id();
|
||||
file(106L, 11L, "9", "image/png");
|
||||
var firstReplaced = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", new ReceiptPhotos(4, "multi-r1-new", first, List.of(106L))));
|
||||
assertThat(firstReplaced.photosNeedUpdate()).isFalse();
|
||||
assertThat(firstReplaced.photosNeedUpdate()).isTrue();
|
||||
assertThat(firstReplaced.rejectionReason()).isNull();
|
||||
assertThat(firstReplaced.receipts().get(1).fileIds()).containsExactly(104L);
|
||||
assertThat(firstReplaced.receivedQuantity()).isEqualTo(10);
|
||||
var resubmitted = tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(5, "multi-again")));
|
||||
@@ -119,6 +123,27 @@ class ProcurementWorkflowPersistenceTest {
|
||||
assertThat(resubmitted.photosNeedUpdate()).isFalse();
|
||||
}
|
||||
|
||||
/** 首批换图清除原因后,其他批次仍可继续换图且不得重复增加库存。 */
|
||||
@Test void replacingPhotosContinuesAcrossBatchesAfterRejectionReasonIsCleared() {
|
||||
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "photos-r1", 4, List.of(101L))));
|
||||
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "photos-r2", 6, List.of(104L))));
|
||||
tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "photos-ask")));
|
||||
var rejected = tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "photos-reject", "照片不清晰")));
|
||||
file(106L, 11L, "9", "image/png");
|
||||
file(107L, 11L, "9", "image/png");
|
||||
var firstRequest = new ReceiptPhotos(4, "photos-first", rejected.receipts().get(0).id(), List.of(106L));
|
||||
var first = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", firstRequest));
|
||||
assertThat(first.rejectionReason()).isNull();
|
||||
assertThat(first.photosNeedUpdate()).isTrue();
|
||||
var second = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", new ReceiptPhotos(5, "photos-second", rejected.receipts().get(1).id(), List.of(107L))));
|
||||
assertThat(second.rejectionReason()).isNull();
|
||||
assertThat(second.photosNeedUpdate()).isFalse();
|
||||
assertThat(second.receivedQuantity()).isEqualTo(10);
|
||||
assertThat(second.stockQuantity()).isEqualTo(10);
|
||||
assertThat(orders.findNo("9", "CG-1").getCustomerRejectionReason()).isNull();
|
||||
assertThat(tx.execute(s -> service.replacePhotos("9", "CG-1", "7", firstRequest)).version()).isEqualTo(second.version());
|
||||
}
|
||||
|
||||
/** 多批次均保留原照片也可重新提交,成功后清除上一轮原因,重放不改变库存。 */
|
||||
@Test void rejectedPurchaseCanResubmitWithoutUploadingPhotos() {
|
||||
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "keep-r1", 4, List.of(101L))));
|
||||
|
||||
+2
-1
@@ -87,7 +87,8 @@ class OrderBudgetServiceTest {
|
||||
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG-OTHER",v->v)).hasMessageContaining("其他订单");
|
||||
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG-1",v->v)).hasMessageContaining("变化");
|
||||
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG-1",v->v)).hasMessageContaining("变化");
|
||||
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG-1",v->v)).hasMessageContaining("校验");
|
||||
// 新客户的下单会建立自身预占;隔离边界是不能按相同请求键读取其他客户已绑定的原单。
|
||||
assertThatThrownBy(()->service.read("10","procurement","key")).hasMessageContaining("未找到");
|
||||
verifyNoInteractions(wallet);
|
||||
}
|
||||
private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {
|
||||
|
||||
Reference in New Issue
Block a user