修复费用审核前调整和运输取消及对账明细约束

This commit is contained in:
2026-10-30 18:51:28 +08:00
parent 5825739b92
commit 035e7e06af
17 changed files with 592 additions and 41 deletions
@@ -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)))));
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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))));
@@ -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) {