费用提交

This commit is contained in:
2026-10-27 17:41:33 +08:00
parent 57cd211973
commit 423e40e6aa
68 changed files with 1856 additions and 166 deletions
@@ -555,6 +555,7 @@ class DeliveryTransferServiceTest {
verify(dispatchItems, never()).findByDispatchOrder(any(), any());
}
/** 派送详情仅回显当前目的仓货物,并保留独立于审核意见的费用说明。 */
@Test
void deliveryDetailAndBolSourceRowsContainOnlyItsDestinationFbaWarehouse() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
@@ -569,6 +570,10 @@ class DeliveryTransferServiceTest {
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(201L, 901L, "SKU-A", "A", 12L),
new com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku(202L, 902L, "SKU-B", "B", 8L)));
when(tailOrders.selectBatchIds(List.of(901L))).thenReturn(List.of());
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setFeeRemark("本次追加装车服务");
fee.setReviewReason("审核通过");
when(feeSources.listForBusiness("TENANT-A", "DELIVERY_ORDER", 401L)).thenReturn(List.of(fee));
DeliveryResponses.DeliveryDetail detail = service.deliveryDetail("TENANT-A", "DEL-ONT8");
@@ -576,6 +581,7 @@ class DeliveryTransferServiceTest {
assertEquals(1, detail.skus().size());
assertEquals(12, detail.skus().get(0).packageCount());
assertEquals("SKU-A", detail.skus().get(0).skuCode());
assertEquals("本次追加装车服务", detail.fees().get(0).remark());
verify(tailOrders).selectBatchIds(List.of(901L));
}
@@ -37,12 +37,16 @@ class DispatchFeedbackRegressionTest {
when(profilePhotos.download(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71,88)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Download(photo,new byte[]{1,2,3}));
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
when(evidence.upload(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyString(),any())).thenReturn(new DispatchEvidenceGateway.File(303L,"司机.png","image/png",3));
ReflectionTestUtils.setField(f.service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,evidence));
var masterPhotos=new DispatchMasterPhotoService(profilePhotos,evidence);
var loadingPhotos=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class);
ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",loadingPhotos);
ReflectionTestUtils.setField(f.service,"masterPhotos",masterPhotos);
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,supplement?List.of(91L):List.of(),input.driver().evidence().expectedTaskEndAt(),List.of())),
input.plannedDepartureAt(),null,null,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
verify(loadingPhotos).invalidateResourcePhotos(eq(f.repository.find(created.dispatchNo()).getId()),eq(f.actor.operatorId()),anySet());
f.prepareAppointmentNumbers(created.dispatchNo());
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(proof.fileIds()).containsExactlyElementsOf(supplement?List.of(81L,91L):List.of(81L));assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
@@ -11,6 +11,27 @@ import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
/** 本土卡车不建立仓库整车绑定,仍须完成同轮调度及理货审核。 */
@Test void localTruckReviewsUseTransportTripWithoutWarehouseBinding() 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 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);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport);
var fleet=org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"fleet",fleet);
var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master);
var submitted=submit(f,created,"truck-submit");
var dispatched=review(f,f.actor,submitted,false,"truck-dispatch",Decision.APPROVED,null);
var loaded=review(f,f.actor,dispatched,true,"truck-loading",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("APPROVED");
org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(3)).verifyLocalTruckReview(created.dispatchNo());
org.mockito.Mockito.verifyNoInteractions(fleet,master);
}
@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)))));
@@ -22,6 +22,16 @@ class TransportTaskServiceTest {
DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips;
UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order;
final Map<String,String> commands=new HashMap<>();
/** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */
@Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() {
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车");
trip.setStatus("PLANNED");
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("任务结束时间");
trip.setPlannedDepartureAt(LocalDateTime.now(ZoneOffset.UTC));
trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusHours(2));
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("分配有效车辆和司机");
verifyNoInteractions(trucks,files);
}
/** 审核已改变的旧页面不能通过仍相同的车次版本覆盖资源和照片。 */
@Test void staleDispatchVersionCannotAssignEvenWhenTripVersionMatches() {
var root=roots.find("PC1");root.setTaskType("PICKUP");root.setRecordVersion(9L);when(roots.lock("PC1")).thenReturn(root);
@@ -34,6 +44,7 @@ class TransportTaskServiceTest {
roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class);
trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class);
service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(service,"reviewPhotos",mock(TransportReviewPhotos.class));
var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L);
when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1");
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of());
@@ -66,6 +77,16 @@ class TransportTaskServiceTest {
@Test void evidenceDownloadRequiresPersistedConfirmation(){
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
}
/** 刷新运输详情时按命令顺序拆分 B 点取货与 C 点送达照片,不能把两阶段混成一组。 */
@Test void detailSeparatesDepartureAndCompletionEvidence(){
trip.setStatus("COMPLETED");
when(links.evidence("PC1")).thenReturn(List.of("[11]", "[22,23]"));
when(files.metadata(eq(1L),anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile(
Objects.toString(call.getArgument(1), ""), "现场-" + call.getArgument(1) + ".jpg", "image/jpeg", 10L));
var detail=service.detail("PC1");
assertThat(detail.departureEvidence()).extracting(com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile::fileId).containsExactly("11");
assertThat(detail.completionEvidence()).extracting(com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile::fileId).containsExactly("22", "23");
}
/** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */
@Test void approvalCreatesOnlyEligibleUnassignedPickup() {
var facts=mock(FleetPickupRepository.class);
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.financefee;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeSourceController;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
class OverseasFeeRoleAuthorizationTest {
/** 使用真实方法鉴权拦截器验证只读、编辑、审核权限相互独立,拒绝请求不得进入业务层。 */
@ParameterizedTest
@CsvSource({
"platform:overseas-fee:list,true,false,false",
"platform:finance:customer-arap:list,true,false,false",
"platform:finance:fee-source:edit,false,true,false",
"platform:finance:fee-source:review,false,false,true",
"unrelated,false,false,false"
})
void roleCannotPerformOtherRolesOperations(String authority, boolean read, boolean edit, boolean review) {
var sources = mock(OverseasFeeSourceService.class);
var manual = mock(OverseasManualFeeService.class);
when(manual.detail(1L)).thenReturn(new com.baiyue.lastleg.financefee.entity.OverseasFeeSource());
var delivery = mock(OverseasFeeDeliveryService.class);
var factory = new ProxyFactory(new PlatformOverseasFeeSourceController(sources, manual, delivery));
factory.setProxyTargetClass(true);
factory.addAdvisor(AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
var controller = (PlatformOverseasFeeSourceController) factory.getProxy();
var actor = new AuthenticatedUser(7L, 0L, 0L, 1L, "fee-acceptance", "PLATFORM", "WEB", Set.of(authority));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
actor, null, List.of(new SimpleGrantedAuthority(authority))));
try {
check(read, () -> controller.list(actor, "42", null, null, null), sources, manual, delivery);
check(read, () -> controller.detail(actor, 1L), sources, manual, delivery);
check(edit, () -> controller.create(actor, "create", null), sources, manual, delivery);
check(edit, () -> controller.submit(actor, 1L, new ActionRequest("submit")), sources, manual, delivery);
check(edit, () -> controller.cancel(actor, 1L, new ActionRequest("cancel")), sources, manual, delivery);
check(edit, () -> controller.retryFunding(actor, 1L, new ActionRequest("retry")), sources, manual, delivery);
check(review, () -> controller.review(actor, 1L, new ReviewRequest("APPROVE", null, "review")), sources, manual, delivery);
} finally {
SecurityContextHolder.clearContext();
}
}
/** 对每次入口调用同时核对放行结果与拒绝时零业务副作用。 */
private void check(boolean allowed, Runnable action, Object... services) {
clearInvocations(services);
if (allowed) assertDoesNotThrow(action::run);
else {
assertThrows(AccessDeniedException.class, action::run);
verifyNoInteractions(services);
}
}
}
@@ -38,6 +38,7 @@ class OverseasManualFeePersistenceTest {
jdbc.execute(ddl);
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(128),customer_name VARCHAR(200),business_type VARCHAR(64),business_no VARCHAR(128),fee_item_code VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),created_at TIMESTAMP,exchange_rate DECIMAL(20,6),wallet_settlement VARCHAR(1000),payment_voucher_file_ids VARCHAR(1000))");
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128),order_type VARCHAR(32),overseas_warehouse_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128))");
for (String table : java.util.List.of("ll_warehouse_transfer_order", "ll_return_order", "ll_us_truck_order", "ll_local_looseboard_order", "ll_relabel_application", "ll_cargo_discard_application", "ll_inbound_plan", "ll_delivery_order"))
jdbc.execute("CREATE TABLE " + table + "(customer_id VARCHAR(128))");
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
@@ -91,6 +92,19 @@ class OverseasManualFeePersistenceTest {
assertThat(queries.count(query)).isEqualTo(4);
}
/** 仅有采购业务的客户也能被费用候选检索,业务单候选与关联始终按客户隔离。 */
@Test void procurementCustomersAndOptionsRequireOwnedOrder() {
jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES(81,'10003','采购客户','CG-01'),(82,'10004','其他客户','CG-02')");
assertThat(businesses.customers()).extracting("customerId").contains("10003", "10004");
var options = businesses.options("OVERSEAS_PROCUREMENT", "10003", "CG-");
assertThat(options).singleElement().satisfies(option -> {
assertThat(option.businessId()).isEqualTo(81L);
assertThat(option.businessNo()).isEqualTo("CG-01");
});
assertThat(businesses.countOwned("OVERSEAS_PROCUREMENT", 81, "10004", "CG-01")).isZero();
assertThat(businesses.countOwned("OVERSEAS_PROCUREMENT", 81, "10003", "CG-01")).isEqualTo(1);
}
/** 一件代发使用实际订单类型且归属筛选不可能返回另一客户订单。 */
@Test void orderCandidatesRetainRealTypeAndRejectForeignOwnership() {
jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','DS-01','ONE_CLICK_DROPSHIP','8')");
@@ -468,6 +468,19 @@ class OverseasManualFeeServiceTest {
verifyNoInteractions(wallet, outbox);
}
/** 头程目的地为自有海外仓时,未显式选择结算方式默认走余额/授信。 */
@Test void firstLegSelfOwnedOverseasWarehouseDefaultsToWallet() {
fee.setBusinessType("LASTLEG_ORDER");
when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG");
when(businesses.destinationType(91L, "10001")).thenReturn("OVERSEAS");
when(repository.lockFunding(701L)).thenReturn(null);
var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567");
when(sources.create("operator", "self-warehouse-create", request)).thenReturn(fee);
var created = service.create("operator", "self-warehouse-create", request);
assertThat(created.getCollectionMode()).isEqualTo("WALLET");
verify(wallet).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), any(), anyString(), eq(1L));
}
/** 旧失败回执在重试后重放不覆盖新扣款,成功仍只入池一次。 */
@Test void staleFailureReplayCannotOverwriteRetrySettlement() {
when(wallet.publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("settle-retry");
@@ -486,6 +499,36 @@ class OverseasManualFeeServiceTest {
assertThat(service.detail(701L).getAllowedActions()).isEmpty(); verifyNoInteractions(wallet, outbox);
}
/** 新采购主单费用默认冻结余额,冻结完成后审核实扣,重复审核不重复扣款。 */
@Test void procurementOrderFeesUseSharedWalletWorkflow() {
fee.setSourceType("OVERSEAS_PROCUREMENT_FEE"); fee.setBusinessType("OVERSEAS_PROCUREMENT");
fee.setReviewStatus("DRAFT"); fee.setSettlementMode(null);
when(repository.lockFunding(701L)).thenReturn(null);
var submitted = service.submit(701L, "operator", "purchase-submit");
assertThat(submitted.getCollectionMode()).isEqualTo("WALLET");
assertThat(submitted.getWalletStatus()).isEqualTo("FREEZE_PENDING");
var state = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(state.capture());
funding = state.getValue();
when(repository.lockFunding(701L)).thenReturn(funding);
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.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));
verifyNoInteractions(outbox);
}
/** 采购费用读取范围固定为同客户主单,资金状态由共用描述器提供。 */
@Test void procurementFeesRemainScopedToCustomerAndOrder() {
fee.setBusinessType("OVERSEAS_PROCUREMENT");
when(sources.listForBusiness("10001", "OVERSEAS_PROCUREMENT", 91L)).thenReturn(List.of(fee));
assertThat(service.procurementFees("10001", 91L)).containsExactly(fee);
verify(sources).listForBusiness("10001", "OVERSEAS_PROCUREMENT", 91L);
verifyNoInteractions(wallet, outbox);
}
@Test void oldRelabelHoldBlocksEquivalentCatalogItemButNotAnotherService() {
fee.setBusinessType("RELABEL_ORDER"); fee.setFeeItemCode("FEE_TYPE_071"); fee.setFeeItemName("贴标费");
fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null);
@@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementSettledFeeSourceTest {
/** 新采购费用关联主单,从通用来源创建并默认余额支付,旧子费用入口仍隔离。 */
@Test void procurementOrderCanCreateSharedFeeWithWalletDefault() {
var mapper = mock(OverseasFeeSourceMapper.class);
var converter = mock(OverseasFeeCurrencyConverter.class);
var guard = mock(OverseasFeeBusinessGuard.class);
var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class), guard,
mock(OverseasFeeOutboxService.class), converter, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class));
var request = new SaveRequest("9", "客户", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_PROCUREMENT", 11L,
"CG-1", null, null, null, null, null, null, null, "SERVICE", "采购服务费", null, "RECEIVABLE",
null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 27), "CNY",
BigDecimal.TEN, null, null, null, null, null);
when(converter.convert("CNY", BigDecimal.TEN, request.businessDate())).thenReturn(
new OverseasFeeCurrencyConverter.Conversion("CNY", BigDecimal.TEN, BigDecimal.ONE, "2026-10", BigDecimal.TEN));
var result = service.create("7", "purchase-fee", request);
assertThat(result.getBusinessType()).isEqualTo("OVERSEAS_PROCUREMENT");
assertThat(result.getSettlementMode()).isEqualTo("WALLET");
assertThat(result.getReviewStatus()).isEqualTo("DRAFT");
assertThat(result.getWalletSettlement()).isNull();
verify(guard).requireOwned(request);
verify(mapper).insert(result);
}
/** 本土运输不得通过应付来源绕开客户余额和授信扣费。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"US_TRUCK,OVERSEAS_TRUCK_FEE", "LOCAL_LOOSEBOARD,OVERSEAS_LOOSEBOARD_FEE"})
@@ -43,7 +43,7 @@ class OwnedBusinessPersistenceTest {
CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32),
stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),departed_at DATETIME(3),delivery_completed_at DATETIME(3),
loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT,
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128))
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128),task_type VARCHAR(32))
""");
jdbc.execute("""
CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),
@@ -52,6 +52,8 @@ class OwnedBusinessPersistenceTest {
destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128),
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT,plan_id BIGINT,plan_pallet_id BIGINT)
""");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(128),status VARCHAR(32),stops_json LONGTEXT)");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(128),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,trip_id BIGINT,dispatch_no VARCHAR(128),customer_id VARCHAR(128),offer_id BIGINT,status VARCHAR(32),incoming_no VARCHAR(128),receipt_json LONGTEXT)");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128),transfer_no VARCHAR(128),inbound_code VARCHAR(128))");
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),inbound_code VARCHAR(128))");
@@ -40,6 +40,17 @@ class OwnedBusinessProjectionTest {
var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record);
assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km());
}
/** 本土卡车及上门取货读取真实行程,不补造仓库托盘和车辆归属。 */
@Test void transportTasksExposeRouteWithoutInventingLoadingPallets() {
for(String task:List.of("LOCAL_TRUCK","PICKUP")) {
var source=OwnedBusinessFixtures.source();source.setTaskType(task);source.setLoadingConfirmationJson(null);
source.setOwnedVehicleOwnership(null);source.setTransportStopsJson("[{\"name\":\"取货地址\",\"address\":\"起点街道\"},{\"name\":\"收货地址\",\"address\":\"终点街道\"}]");
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,null,List.of(),List.of(),OwnedBusinessFixtures.actor());
assertNull(row.actualPalletCount());assertNull(row.unitPrice());assertNull(row.vehicle().ownership());
assertEquals("取货地址",row.origin().warehouseName());assertEquals("收货地址",row.destinations().get(0).warehouseName());
assertEquals("终点街道",row.destinations().get(0).address());assertFalse(row.sourceIssues().isEmpty());
}
}
/** 虚拟记录和空照片保持真实空态。 */
private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;}
}
@@ -51,6 +51,20 @@ class OwnedBusinessQueryTest {
source.setPooledCargo(true);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setPooledCargo(false);source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
}
/** 运输任务以真实取货发车为依据,未发车和未取齐仍不可进入运营。 */
@Test void transportRequiresDepartureAndDoesNotLeakIntoPooledBusiness() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
for(String task:List.of("LOCAL_TRUCK","PICKUP")) {
var source=OwnedBusinessFixtures.source();source.setTaskType(task);source.setOwnedVehicleOwnership(null);
source.setLoadingConfirmationJson(null);source.setStage("DELIVERING");source.setTransportTripStatus("IN_TRANSIT");
source.setTransportReady(true);source.setDepartedAt(LocalDateTime.parse("2026-10-26T00:00:00"));
when(repo.source("DS-OWN-ONE")).thenReturn(source);
assertSame(source,service.requireOwned("DS-OWN-ONE"));
assertThrows(BizException.class,()->service.requireScoped("DS-OWN-ONE",com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED));
source.setDepartedAt(null);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
}
}
/** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */
@Test void readOnlyActorHasOnlyReadGates() {
var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec())
@@ -57,6 +57,33 @@ class OwnedBusinessScopePersistenceTest {
}
/** 数据库仅在本机隔离H2,用后关闭。 */
@AfterEach void close(){baseline.closeDatabase();}
/** 两类运输仅在实提发车后进自有业务,运营到达保存沿用真实行程键。 */
@Test void transportSourcesAreQueryableAndSupportArrivalRecordsWithoutWarehouseCargo() {
for(int i=10;i<=11;i++) {
String task=i==10?"LOCAL_TRUCK":"PICKUP",number="DS-TRANSPORT-"+i;
baseline.jdbc.update("INSERT INTO ll_fleet_trip(id,trip_no,status,stops_json) VALUES(?,?,'IN_TRANSIT',?)",i,"TR-"+i,
"[{\"name\":\"取货地\"},{\"name\":\"送达地\"}]");
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at,departed_at,
resources_json,vehicle_plate,overseas_warehouse_id,task_type)
SELECT ?,?,?,'ACTIVE','DELIVERING',1,planned_departure_at,planned_departure_at,resources_json,vehicle_plate,overseas_warehouse_id,?
FROM ll_dispatch_order WHERE id=1
""",i,number,i,task);
if(i==11)baseline.jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('TR-11','PICKED')");
var detail=queries.detail(OwnedBusinessFixtures.actor(),number);
assertNull(detail.row().actualPalletCount());assertEquals(task,detail.row().taskType());assertEquals(1,detail.row().destinations().size());
assertThrows(BizException.class,()->outsourced.detail(actor(),number));
var saved=records.save(OwnedBusinessFixtures.actor(),number,new OwnedBusinessModels.SaveOperations("0","save-transport-"+i,
new OwnedBusinessModels.Operations(null,null,null,List.of(new OwnedBusinessModels.Arrival("TRANSPORT_STOP:1","2026-10-26T00:00:00Z"))),null));
assertEquals("2026-10-26T00:00:00Z",saved.row().destinations().get(0).actualArrivedAt());
}
var q=new OwnedBusinessModels.Query(1,100,null,null,null,null,null);
var rows=queries.list(OwnedBusinessFixtures.actor(),q).records();
assertEquals(2,rows.stream().filter(r->r.dispatchNo().startsWith("DS-TRANSPORT-")).count());
baseline.jdbc.update("UPDATE ll_fleet_pickup_task SET status='ASSIGNED' WHERE trip_no='TR-11'");
assertFalse(queries.list(OwnedBusinessFixtures.actor(),q).records().stream().anyMatch(r->r.dispatchNo().equals("DS-TRANSPORT-11")));
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-TRANSPORT-11"));
}
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
@SuppressWarnings("unchecked") private <T>T tx(T target) {
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
@@ -71,7 +98,7 @@ class OwnedBusinessScopePersistenceTest {
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,task_type FROM ll_dispatch_order WHERE id=1
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
baseline.jdbc.update("""
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
@@ -20,6 +20,18 @@ class TripOperationsServiceTest {
"externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"},
"receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]}
""";
/** 两类运输发车没有仓库装载快照,也能独立列示真实出车且不伪造实托。 */
@Test void transportDeparturesUseTheirOwnEvidenceAndKeepPalletsUnknown() {
var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
for(String purpose:List.of("LOCAL_TRUCK","PICKUP")) {
String snapshot="{\"trip\":{\"tripNo\":\"TR-T\",\"tripPurpose\":\""+purpose+"\",\"departedAt\":\"2026-10-26T00:00:00Z\",\"vehiclePlate\":\"TEST\",\"stops\":[{\"name\":\"取货地\"},{\"name\":\"送达地\"}]}}";
var row=service.project(new TripOperationsRepository.Departure("TR-T","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\""+purpose+"\",\"packages\":10}"));
assertThat(row.ownPackages()).isEqualTo(10);assertThat(row.externalPackages()).isZero();
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
assertThat(row.destinations().get(0).label()).isEqualTo("送达地");
assertThat(TripOperationsCharts.from(List.of(row)).physicalPallets().get(0).points()).isEmpty();
}
}
@Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() {
var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
@@ -8,6 +8,43 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementOpenApiContractTest {
/** 简化采购契约须覆盖分批作业、客户确认、库存与受控文件读取。 */
@Test
void simplifiedWorkflowDocumentsActualBatchContractAndCustomerIsolation() throws Exception {
String yaml;
try (var input = getClass().getResourceAsStream("/openapi/procurement-workflow-v1.yaml")) {
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}";
for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:",
"/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) {
assertTrue(yaml.contains(platform + suffix), suffix);
}
String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}";
assertTrue(yaml.contains(merchant + "/workflow:"));
assertTrue(yaml.contains(merchant + "/confirm:"));
assertTrue(yaml.contains(merchant + "/files/{fileId}/content:"));
for (String permission : new String[]{"platform:overseas-business:purchase:list",
"platform:overseas-business:purchase:receive-overseas",
"platform:overseas-business:purchase:ship-customer",
"merchant:cross-border:procurement:list", "merchant:cross-border:procurement:create"}) {
assertTrue(yaml.contains(permission), permission);
}
assertTrue(yaml.contains("enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED]"));
assertTrue(yaml.contains("required: [version, idempotencyKey]"));
assertTrue(yaml.contains("required: [quantity, trackingNo]"));
assertTrue(yaml.contains("required: [quantity, fileIds]"));
assertTrue(yaml.contains("maxItems: 20"));
assertTrue(yaml.contains("uniqueItems: true"));
assertTrue(yaml.contains("receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files"));
assertTrue(yaml.contains("内部文件和其他客户文件均不可下载"));
assertTrue(yaml.contains("format: binary"));
assertTrue(yaml.contains("no-store"));
String merchantPaths = yaml.substring(yaml.indexOf(merchant + "/workflow:"), yaml.indexOf("components:"));
assertFalse(merchantPaths.contains("#/components/parameters/CustomerId"));
assertFalse(merchantPaths.contains("purchase:review"));
}
@Test
void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception {
String yaml;
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.service.*;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 正式方法权限与客户隔离均在控制器边界验证。 */
class ProcurementWorkflowAuthorizationTest {
AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class);
ProcurementWorkflowService service=context.getBean(ProcurementWorkflowService.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean ProcurementWorkflowService service() { return mock(ProcurementWorkflowService.class); }
@Bean ProcurementWorkflowController platform(ProcurementWorkflowService service) { return new ProcurementWorkflowController(service); }
@Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock() {
return f->f.registerSingleton("orders",mock(OverseasProcurementService.class));
}
@Bean MerchantProcurementWorkflowController merchant(ProcurementWorkflowService service,OverseasProcurementService orders) { return new MerchantProcurementWorkflowController(service,orders); }
}
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
@Test void readerCannotReceiveOrCreateOutbounds() {
var controller=context.getBean(ProcurementWorkflowController.class);
var user=login(0,"PLATFORM","platform:overseas-business:purchase:list");
controller.read(user,"9","CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class);
}
@Test void receiverCanSubmitConfirmationButCannotShip() {
var controller=context.getBean(ProcurementWorkflowController.class);
var user=login(0,"PLATFORM","platform:overseas-business:purchase:receive-overseas");
var command=new Command(1,"ask");controller.requestConfirmation(user,"9","CG-1",command);
verify(service).requestConfirmation("9","CG-1","7",command);
assertThatThrownBy(()->controller.dispatch(user,"9","CG-1",1,command)).isInstanceOf(AccessDeniedException.class);
}
@Test void customerCannotUsePlatformEvenWithInjectedAuthorityAndCannotChooseCustomer() {
var platform=context.getBean(ProcurementWorkflowController.class);
var user=login(9,"MERCHANT","platform:overseas-business:purchase:ship-customer");
assertThatThrownBy(()->platform.outbound(user,"10","CG-1",new Outbound(0,"o",1,"T"))).isInstanceOf(ResponseStatusException.class);
var merchant=context.getBean(MerchantProcurementWorkflowController.class);
var customer=login(9,"MERCHANT","merchant:cross-border:procurement:create");
var command=new Command(1,"c");merchant.confirm(customer,"CG-1",command);verify(service).confirm("9","CG-1","7",command);
}
@Test void customerListPermissionCannotConfirm() {
var merchant=context.getBean(MerchantProcurementWorkflowController.class);
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
merchant.read(user,"CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
}
}
@@ -138,14 +138,14 @@ class OverseasProcurementServiceTest {
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L);
var approved = service.review("100", "HC1", "operator-7", request);
assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status());
assertEquals("RECEIVING", approved.summary().status());
assertEquals(1L, approved.summary().version());
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
verify(histories).insert(org.mockito.ArgumentMatchers
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
&& "approve-1".equals(value.getIdempotencyKey())
&& OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus())));
&& "RECEIVING".equals(value.getToStatus())));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L)));
@@ -153,7 +153,7 @@ class OverseasProcurementServiceTest {
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L)));
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L)));
assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus());
assertEquals("RECEIVING", order.getStatus());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
@@ -115,13 +115,10 @@ class ProcurementPurchasePersistenceTest {
@Test void unreviewedPurchaseFactCannotConfirmOrCharge() {
jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11");
var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(recorded.purchase().recorded()).isTrue();
assertThat(recorded.purchase().confirmed()).isFalse();
assertThat(recorded.purchase().issue()).contains("尚未审核");
var again=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-unreviewed","actual fact")));
assertThat(again.purchase().confirmed()).isFalse();
assertThat(again.status()).isEqualTo("PENDING_REVIEW");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("简化采购");
assertThat(service.flow(11)).isEmpty();
assertThat(orders.findNo("9","PO-1").getVersion()).isZero();
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@@ -30,6 +30,7 @@ class ProcurementQueryServiceTest {
base=mock(OverseasProcurementService.class);
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class));
}
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception {
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11");
@@ -60,12 +61,40 @@ class ProcurementQueryServiceTest {
@Test void anotherCustomerCannotLoadEvenPublicProjection() {
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base);
}
/** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */
@Test void customerReadsOnlyOwnedManualProcurementFeeResults() throws Exception {
var manual = mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class);
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", manual);
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setSourceFeeId(901L); fee.setSourceVersion(1); fee.setCustomerId("9"); fee.setBusinessId(11L);
fee.setBusinessType("OVERSEAS_PROCUREMENT"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE");
fee.setFeeItemCode("SERVICE"); fee.setFeeItemName("采购服务费"); fee.setReviewStatus("PENDING");
fee.setWalletStatus("FREEZE_PENDING"); fee.setWalletErrorMessage("private funding diagnosis");
fee.setAmount(BigDecimal.TEN); fee.setCurrency("CNY"); fee.setReviewReason("private review reason");
var other = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
other.setBusinessType("OVERSEAS_PROCUREMENT"); other.setDirection("RECEIVABLE");
other.setCustomerId("10"); other.setBusinessId(11L);
when(manual.procurementFees("9", 11L)).thenReturn(List.of(fee, other));
var result = query.customer("9", "PO-1");
assertThat(result.fees()).singleElement().satisfies(row -> {
assertThat(row.feeNo()).isEqualTo("901");
assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");
assertThat(row.sourceLinked()).isFalse();
});
assertThat(json.writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason");
}
@Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() {
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')");
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)");
assertThat(query.customer("9","PO-1").boxes()).isEmpty();
}
/** 新流程不再提示旧购买、装箱和两段核费动作。 */
@Test void simplifiedWorkflowDoesNotExposeLegacySampleActions() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='RECEIVING' WHERE id=11");
assertThat(query.platform("9", "PO-1", Set.of("platform:overseas-business:purchase:buy")).actions()).isEmpty();
}
@Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT);
var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas"));
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实Mapper验证采购库存分批、隔离、事务与幂等。 */
class ProcurementWorkflowPersistenceTest {
JdbcTemplate jdbc;
TransactionTemplate tx;
ProcurementWorkflowService service;
OverseasProcurementOrderMapper orders;
OverseasProcurementFileMapper files;
OverseasFeeSourceService fees;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(ProcurementWorkflowMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());orders=session.getMapper(OverseasProcurementOrderMapper.class);files=session.getMapper(OverseasProcurementFileMapper.class);
var repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet());
fees=mock(OverseasFeeSourceService.class);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));
service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules(),fees);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order);
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
}
@Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() {
var first=new Receipt(0,"receive1",4,List.of(101L));
var current=tx.execute(s->service.receive("9","CG-1","7",first));
assertThat(current.stockQuantity()).isEqualTo(4);
current=tx.execute(s->service.receive("9","CG-1","7",new Receipt(1,"receive2",6,List.of(104L))));
assertThat(current.receipts()).hasSize(2);assertThat(current.availableQuantity()).isEqualTo(10);
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(2,"ask")));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"early",4,"TN")))).isInstanceOf(ResponseStatusException.class);
tx.execute(s->service.confirm("9","CG-1","8",new Command(3,"confirm")));
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"out1",4,"TN-1")));
long batch1=current.outbounds().get(0).id();
assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.reservedQuantity()).isEqualTo(4);assertThat(current.availableQuantity()).isEqualTo(6);
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(5,"out2",6,"TN-2")));
long batch2=current.outbounds().get(1).id();
tx.execute(s->service.dispatch("9","CG-1",batch1,"7",new Command(6,"dispatch1")));
var completion=new Completion(7,"complete1",List.of(102L));
current=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion));
assertThat(current.stockQuantity()).isEqualTo(6);assertThat(current.reservedQuantity()).isEqualTo(6);assertThat(current.status()).isEqualTo("IN_TRANSIT");
var replay=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion));
assertThat(replay.version()).isEqualTo(8);assertThat(replay.stockQuantity()).isEqualTo(6);
tx.execute(s->service.dispatch("9","CG-1",batch2,"7",new Command(8,"dispatch2")));
current=tx.execute(s->service.complete("9","CG-1",batch2,"7",new Completion(9,"complete2",List.of(101L))));
assertThat(current.status()).isEqualTo("COMPLETED");assertThat(current.stockQuantity()).isZero();assertThat(current.availableQuantity()).isZero();
assertThat(orders.findNo("9","CG-1").getInboundQuantity()).isZero();
assertThat(current.files()).hasSize(3);
}
@Test void partialPurchaseCanFinishButRemainingStockRequiresMoreOutbounds() {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",6,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
var current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"o",2,"TRACK")));
long batch=current.outbounds().get(0).id();
tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d")));
current=tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"c",List.of(102L))));
assertThat(current.status()).isEqualTo("CONFIRMED");assertThat(current.stockQuantity()).isEqualTo(4);
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"o2",4,"TRACK-2")));
assertThat(current.availableQuantity()).isZero();assertThat(current.outbounds()).hasSize(2);
}
@Test void validatesQuantityPhotosCustomerFilesVersionsAndIdempotency() {
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"pdf",1,List.of(102L))))).hasMessageContaining("图片");
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"foreign",1,List.of(103L))))).hasMessageContaining("不属于");
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"over",11,List.of(101L))))).hasMessageContaining("超过");
assertThatThrownBy(()->service.read("10","CG-1")).hasMessageContaining("不属于");
assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"empty")))).hasMessageContaining("收货");
var request=new Receipt(0,"r",6,List.of(101L));tx.execute(s->service.receive("9","CG-1","7",request));
assertThat(tx.execute(s->service.receive("9","CG-1","7",request)).version()).isEqualTo(1);
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",5,List.of(101L))))).hasMessageContaining("幂等键");
assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"stale")))).hasMessageContaining("已更新");
service.requireVisibleFile("9","CG-1",101L);
assertThatThrownBy(()->service.requireVisibleFile("9","CG-1",102L)).hasMessageContaining("未关联");
}
@Test void concurrentOutboundsCannotOversellAndCompletionRequiresDispatchAndProof() throws Exception {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
var pool=Executors.newFixedThreadPool(2);
try {
var start=new CountDownLatch(1);var succeeded=new AtomicInteger();
var tasks=new ArrayList<Future<?>>();
for(int i=0;i<2;i++) {int index=i;tasks.add(pool.submit(()->{try {start.await();tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"out"+index,7,"TN"+index)));succeeded.incrementAndGet();}catch(ResponseStatusException expected) { }catch(InterruptedException e) {throw new RuntimeException(e);}}));}
start.countDown();for(var future:tasks)future.get(10,TimeUnit.SECONDS);
assertThat(succeeded.get()).isEqualTo(1);
} finally {pool.shutdownNow();}
var current=service.read("9","CG-1");assertThat(current.availableQuantity()).isEqualTo(3);
long batch=current.outbounds().get(0).id();
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"over",4,"T")))).hasMessageContaining("超过");
assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(4,"early",List.of(102L))))).hasMessageContaining("派送中");
tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d")));
assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"empty",List.of())))).hasMessageContaining("凭证");
assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10);
}
@Test void outboundRequiresCurrentReviewedReceivablesAndRejectedAttemptsLeaveNoInventoryChanges() {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
var request=new Outbound(3,"out",3,"TRACK");
for(var rows:List.of(List.<OverseasFeeSource>of(),List.of(fee("VOIDED","APPROVED")))) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(rows);
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
}
var payable=fee("ACTIVE","APPROVED");payable.setDirection("PAYABLE");
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(payable));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
for(String review:List.of("PENDING","REJECTED")) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("ACTIVE",review)));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("尚未全部审核通过");
}
assertThat(service.read("9","CG-1").version()).isEqualTo(3);
assertThat(service.read("9","CG-1").reservedQuantity()).isZero();
assertThat(service.read("9","CG-1").outbounds()).isEmpty();
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("VOIDED","REJECTED")));
var result=tx.execute(s->service.outbound("9","CG-1","7",request));
assertThat(result.reservedQuantity()).isEqualTo(3);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of());
assertThat(tx.execute(s->service.outbound("9","CG-1","7",request)).version()).isEqualTo(4);
}
private OverseasFeeSource fee(String status,String review) {
var fee=new OverseasFeeSource();fee.setDirection("RECEIVABLE");fee.setSourceStatus(status);fee.setReviewStatus(review);return fee;
}
private void file(long id,long orderId,String customer,String type) {
var file=new OverseasProcurementFile();file.setId(id);file.setOrderId(orderId);file.setCustomerId(customer);file.setFileId(id);file.setCategory("RECEIPT");file.setFileName("proof-"+id);file.setContentType(type);file.setCreatedBy("7");files.insert(file);
}
}
@@ -97,14 +97,12 @@ class UsTruckOrderServiceTest {
}
@Test
void firstActualReceiptAcceptsRealQuantityWithoutForecastOverage() {
void reviewEntersDispatchWithoutInventingPallets() {
order.setPalletCount(0);
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null));
var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null, 1L));
assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status());
assertTrue(palletRows.isEmpty());
var received = service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), "first-real", null));
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, received.summary().status());
assertEquals(3, received.summary().receivedPalletCount());
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
assertEquals(0, reviewed.summary().receivedPalletCount());
}
@Test
@@ -116,90 +114,53 @@ class UsTruckOrderServiceTest {
@Test
void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() {
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve");
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "approve", "approve", 1L);
var reviewed = service.review("100", "TK1", "operator-7", review);
assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status());
assertEquals(2, palletRows.size());
assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status());
assertTrue(palletRows.isEmpty());
assertEquals(1L, reviewed.summary().version());
assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version());
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7",
new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different")));
var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2),
"receive-1", "receive overage");
var received = service.receivePallets("100", "TK1", "receiver-8", receive);
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status());
assertEquals(3, received.summary().receivedPalletCount());
assertEquals(3, palletRows.size());
assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version());
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE",
"overage-1", "approve overage");
var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage);
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status());
assertEquals(3, overageReviewed.summary().palletCount());
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
Long first = palletRows.get(0).getId();
List<Long> remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList();
var partial = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial"));
assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status());
assertEquals(1, partial.summary().allocatedPalletCount());
var allocated = service.allocatePallets("100", "TK1", "allocator-10",
new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all"));
assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status());
assertEquals(3, allocated.summary().allocatedPalletCount());
assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11",
command(5L, "prepare-1")).summary().status());
assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11",
command(6L, "dispatch-1")).summary().status());
command(1L, "dispatch-1")).summary().status());
assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12",
command(7L, "transit-1")).summary().status());
var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1"));
command(2L, "transit-1")).summary().status());
var completed = service.complete("100", "TK1", "driver-12", command(3L, "complete-1"));
assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status());
assertEquals(9L, completed.summary().version());
assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1"))
assertEquals(4L, completed.summary().version());
assertEquals(4L, service.complete("100", "TK1", "driver-12", command(3L, "complete-1"))
.summary().version());
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden")));
new UsTruckOrderRequests.ChangeStatus(4L, "CANCELLED", "cancel-terminal", "forbidden")));
assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12",
command(8L, "reverse-state")));
command(3L, "reverse-state")));
assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11",
command(1L, "stale-version")));
assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE",
"ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH",
"START_TRANSIT", "COMPLETE"),
assertEquals(List.of("CREATE", "REVIEW", "DISPATCH", "START_TRANSIT", "COMPLETE"),
historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList());
assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null
&& row.getIdempotencyKey() != null));
}
@Test
void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-partial", null));
var partial = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null));
assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status());
var exact = service.receivePallets("100", "TK1", "receiver",
new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null));
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status());
void reviewIsIdempotentAtDispatchBoundary() {
var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-partial", null, 1L));
assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status());
assertEquals(1L, service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-partial", null, 1L)).summary().version());
}
@Test
void rejectedOverageExcludesExtraPalletFromAcceptedCount() {
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "APPROVE", "review-reject-overage", null));
service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(
1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null));
var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer",
new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null));
assertEquals(2, reviewed.summary().receivedPalletCount());
assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus());
void rejectedReviewDoesNotEnterDispatch() {
var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
0L, "REJECT", "review-reject", "资料不完整"));
assertEquals(UsTruckOrderService.REJECTED, reviewed.summary().status());
assertTrue(palletRows.isEmpty());
}
private UsTruckOrderRequests.Command command(long version, String key) {
@@ -222,3 +183,4 @@ class UsTruckOrderServiceTest {
return value;
}
}