This commit is contained in:
2026-11-03 11:41:58 +08:00
parent 21a9ce7d27
commit 7b5ef9ba64
56 changed files with 679 additions and 173 deletions
@@ -67,14 +67,14 @@ class CargoDiscardMysqlAcceptanceTest {
new CargoDiscardRequests.Item(990001001L, null, "SKU-BOX-RED", 3),
new CargoDiscardRequests.Item(990001001L, null, "SKU-RACK-KITCHEN", 2)),
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
var approved = service.create(CUSTOMER, "acceptance-creator", "验收申请人", approveRequest);
assertEquals(20L, skuPackages(990001001L));
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
assertEquals(15L, skuPackages(990001001L));
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var rejected = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001002L, null, "SKU-LAMP-WHITE", 4)),
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
assertEquals(10L, skuPackages(990001002L));
@@ -83,13 +83,13 @@ class CargoDiscardMysqlAcceptanceTest {
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
assertEquals(10L, skuPackages(990001002L));
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var pending = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 2)),
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
assertEquals(7L, skuPackages(990001003L));
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", "验收申请人",
new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 99)),
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 用真实 Mapper 查询验证废弃扫码的来源隔离和已移出箱拦截。 */
class CargoDiscardTraceableCartonMapperTest {
/** 原入仓箱和净实收流水可溯源,其他订单、客户、托盘及已移出箱均不得混入。 */
@Test void onlyCurrentSourceCartonsAreTraceable() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(20),position_id BIGINT,balance_id BIGINT,inbound_order_id BIGINT,carton_mark VARCHAR(80),delta_packages BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,received_at TIMESTAMP,status VARCHAR(30),carton_mark VARCHAR(80),destination_warehouse_code VARCHAR(20))");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100)");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA')");
db.update("INSERT INTO ll_inventory_ledger VALUES('C',41,1,10,'LEDGER',1),('C',41,1,10,'MOVED',1),('C',41,1,10,'MOVED',-1),('C',41,1,20,'FOREIGN-ORDER',1),('X',41,1,10,'FOREIGN-CUSTOMER',1)");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('C',10,100,CURRENT_TIMESTAMP,'AVAILABLE','INBOUND','FBA'),('C',10,200,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-PALLET','FBA'),('C',20,100,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-ORDER','FBA'),('C',10,100,CURRENT_TIMESTAMP,'DISPATCHED','SHIPPED','FBA')");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
assertEquals(java.util.Set.of("LEDGER", "INBOUND"), new java.util.HashSet<>(mapper.traceableCartonMarks("C", 41L)));
assertTrue(mapper.traceableCartonMarks("X", 41L).isEmpty());
}
}
}
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "stockService", mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
@@ -127,6 +127,7 @@ class CargoDiscardServiceTest {
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "profiles", org.mockito.Mockito.mock(com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper.class));
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
@@ -152,28 +153,11 @@ class CargoDiscardServiceTest {
lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories));
}
@Test
void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order();
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
/** 新建废弃不能通过旧订单参数绕过一件代发过滤。 */
@Test void dropshipCreationIsRejectedBeforeReservation() {
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () ->
service.create("100", "7", null, new CargoDiscardRequests.Create(List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "key", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
@@ -185,41 +169,10 @@ class CargoDiscardServiceTest {
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
CargoDiscardCandidateRow skuB = candidate("SKU-B", 20L);
when(inventory.candidates("100", "RK-1")).thenReturn(List.of(skuA, skuB));
when(inventory.countAvailableOrder("100", 101L)).thenReturn(20L);
var result = service.candidates("100", "RK-1");
assertEquals(1, result.size());
assertEquals(20, result.get(0).availablePackages());
assertEquals(2, result.get(0).skus().size());
/** 即使存在一件代发可用箱,也不向货物废弃候选暴露。 */
@Test void candidatesExcludeDropshipOrders() {
assertEquals(List.of(), service.candidates("100", "RK-1"));
verify(inventory, never()).candidates(any(), any());
}
@Test
@@ -264,15 +217,20 @@ class CargoDiscardServiceTest {
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
var detail = service.create("100", "OP", "申请人姓名", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals("申请人姓名", detail.summary().createdByName());
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
carton.setPalletCode("RETURN-PALLET");
storedItems.forEach(line -> { line.setPdaStatus("COMPLETED"); line.setActualDiscardPackages(line.getDiscardPackages()); });
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals("RETURN-PALLET", existing.items().get(0).palletNo());
assertEquals(List.of("BOX-1"), existing.items().get(0).actualCartonMarks());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@@ -54,6 +54,15 @@ class CargoDiscardStockServiceTest {
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
/** 非本单原入仓箱唛必须在任何扣库存前拒绝。 */
@Test void unknownAndForeignCartonsCannotPassQuantityScanning() {
var line = new CargoDiscardItem(); line.setInventoryPositionId(42L);
when(stock.traceableCartonMarks("C1",42L)).thenReturn(List.of("OWN-BOX"));
service.validateActualCartonMarks("C1", line, List.of("OWN-BOX"));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("C1", line, List.of("RANDOM")));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("OTHER", line, List.of("OWN-BOX")));
verifyNoInteractions(commands);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
@@ -28,6 +28,8 @@ class DispatchDeparturePersistenceTest {
var b=executor.submit(()->fixture.tryDepart(start,new Actor("picker",ActorMode.PDA,"9"),pdaRequest));
start.countDown();assertThat(List.of(a.get(20,TimeUnit.SECONDS),b.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {executor.shutdownNow();}
// 发车属于作业阶段变化,单据仍有效;不得重新写入旧版DEPARTED状态。
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE");
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING");
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(fixture.f.db.queryForObject("SELECT shipped_package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(8);
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchOutboundPort;
import com.baiyue.lastleg.fleet.trip.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -42,6 +42,18 @@ class TransportTaskServiceTest {
assertThatThrownBy(()->service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消"))).hasMessage("已进入运输取消");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 过期计划不阻止审核前取消;取消不重新核验司机或运输时间。 */
@Test void expiredLocalTruckPlanCanStillCancel() {
var root=roots.find("PC1");root.setStage("BEFORE_LOADING");root.setReviewState("DRAFT");
when(roots.lock("PC1")).thenReturn(root);
trip.setStatus("PLANNED");
trip.setPlannedDepartureAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(2));
trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(1));
doAnswer(call->{trip.setStatus("CANCELLED");return null;}).when(trips).cancel(eq("TR1"),eq("OP"),any());
service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消过期计划"));
assertThat(trip.getStatus()).isEqualTo("CANCELLED");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */
@Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() {
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车");
@@ -390,16 +390,30 @@ class OverseasManualFeeServiceTest {
verifyNoInteractions(outbox);
}
/** 自动仓租项目不能通过人工入口重复建应收或锁款。 */
@Test void automaticRentItemCannotBeCreatedManually() throws Exception {
/** 人工仓租沿人工费用资金身份冻结和审核,不进入自动仓租链。 */
@Test void storageRentItemCanBeCreatedManually() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree(
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"));
data.put("feeItemCode", "FEE_TYPE_077"); data.put("feeItemName", "仓储费");
var request = mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
assertThatThrownBy(() -> service.create("operator", "manual-rent", request))
.hasMessageContaining("仓租自动计算");
verifyNoInteractions(sources, repository, catalog, wallet, outbox);
fee.setFeeItemCode("FEE_TYPE_077"); fee.setFeeItemName("仓储费");
when(sources.create("operator", "manual-rent", request)).thenReturn(fee);
when(repository.lockFunding(701L)).thenReturn(null);
var created = service.create("operator", "manual-rent", request);
assertThat(created.getReviewStatus()).isEqualTo("PENDING");
assertThat(StorageRentFeeWorkflow.isRent(created)).isFalse();
verify(catalog).requireActive("FEE_TYPE_077", "仓储费", request.direction());
var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(captured.capture());
verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
eq(new BigDecimal("88.00")), anyString(), eq(1L));
captured.getValue().setWalletStatus("FROZEN");
when(repository.lockFunding(701L)).thenReturn(captured.getValue());
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "manual-rent-approve"));
verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
isNull(), anyString(), eq(2L));
verifyNoInteractions(outbox);
}
@Test void createReplayDoesNotSendSecondFreezeCommand() {
@@ -46,13 +46,37 @@ class StorageRentCalculatorTest {
assertThat(amount("TRANSIT", 31, "2", "10", "6")).isEqualByComparingTo("23.28");
}
/** 已进位的一托按原商品比例分摊,剩余六成不能再次进位成一托。 */
/** 退货不足一托也按整托,部分商品出库不能按件数摊薄原托仓租。 */
@Test
void apportionsTheOriginalRoundedPalletAcrossShipments() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("6");
assertThat(amount("RETURN", 40, "1", "100", "60")).isEqualByComparingTo("39");
var thirds = amount("RETURN", 10, "1", "3", "1");
assertThat(thirds.multiply(new BigDecimal("3"))).isEqualByComparingTo("15");
void chargesWholePalletEvenWhenOnlyOnePieceLeaves() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("15");
assertThat(amount("RETURN", 40, "1", "100", "1")).isEqualByComparingTo("65");
assertThat(amount("RETURN", 1, "0.2", "16", "9")).isEqualByComparingTo("1.5");
}
/** 部分出库后仅对新增库龄收整托阶梯价,同一计租日不重复收取。 */
@Test
void chargesOnlyUnbilledDaysAcrossAllReturnTiers() {
var pricing = StorageRentCalculator.defaults(null);
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 0)).isEqualByComparingTo("15");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 10)).isZero();
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(40), BigDecimal.ONE, 10)).isEqualByComparingTo("50");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(61), BigDecimal.ONE, 40)).isEqualByComparingTo("43");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(91), BigDecimal.ONE, 61)).isEqualByComparingTo("91");
assertThatThrownBy(() -> StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(1), BigDecimal.ONE, 2))
.isInstanceOf(IllegalArgumentException.class);
}
/** 解释明细必须截取实际未计费天数,跨阶梯分行,免费区间也能解释为零。 */
@Test void explainsTierDaysUnitsAndUnroundedAmounts() {
var pricing=StorageRentCalculator.defaults(null);
var tiers=StorageRentCalculator.breakdown(pricing,"RETURN",10,40,BigDecimal.ONE);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::days).containsExactly(20L,10L);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::amount)
.usingComparatorForType(BigDecimal::compareTo,BigDecimal.class).containsExactly(new BigDecimal("30"),new BigDecimal("20"));
assertThat(StorageRentCalculator.breakdown(pricing,"TRANSIT",0,7,new BigDecimal("0.8"))).hasSize(1)
.allSatisfy(t->assertThat(t.amount()).isZero());
assertThat(StorageRentCalculator.breakdown(pricing,"RETURN",1,1,BigDecimal.ONE)).isEmpty();
}
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.storagerent.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
import com.baiyue.lastleg.storagerent.mapper.StorageRentLotMapper;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 真实 Mapper 和行锁验证整托计费截止日与出库数量的原子性。 */
class StorageRentPalletPeriodPersistenceTest {
/** 两个同时出库事务只生成一份当日整托租金,失败事务不能推进计费日或扣余量。 */
@Test void serializesWholePalletDaysAndRollsBackFailedOutbound() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
String baseline;
try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) {baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8);}
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (");
String ddl=baseline.substring(start,baseline.indexOf(';',start));
ddl=ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?","");
ddl=ddl.substring(0,ddl.indexOf(") ENGINE="))+")";ddl=ddl.replaceAll(",\\s*\\)$",")");
new JdbcTemplate(ds).execute(ddl);
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(StorageRentLotMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(StorageRentLotMapper.class);
var repository=new StorageRentRepository();ReflectionTestUtils.setField(repository,"lots",mapper);
var tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
var inbound=LocalDateTime.of(2026,11,2,10,0);
var lot=new StorageRentLot();lot.setId(1L);lot.setSourceKey("RETURN:1");lot.setCustomerId("10");lot.setWarehouseId("20");
lot.setBusinessType("RETURN_ORDER");lot.setBusinessId(1L);lot.setBusinessNo("TH1");lot.setRuleCode("RETURN");
lot.setOriginalQuantity(new BigDecimal("16"));lot.setRemainingQuantity(new BigDecimal("16"));lot.setOriginalUnits(BigDecimal.ONE);
lot.setInboundAt(inbound);lot.setCreatedAt(inbound);lot.setUpdatedAt(inbound);repository.insert(lot);
assertThatThrownBy(()->tx.execute(status->{var locked=repository.lockLot(1L);locked.setChargedDays(1L);
locked.setRemainingQuantity(new BigDecimal("15"));repository.save(locked);throw new IllegalStateException("出库失败");}))
.hasMessageContaining("出库失败");
assertThat(repository.lot(1L).getChargedDays()).isZero();assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("16");
var firstLocked=new CountDownLatch(1);var secondStarted=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var first=workers.submit(()->tx.execute(status->{var locked=repository.lockLot(1L);firstLocked.countDown();
await(secondStarted);return consumeDay(repository,locked,inbound.plusHours(1));}));
assertThat(firstLocked.await(5,TimeUnit.SECONDS)).isTrue();
var second=workers.submit(()->tx.execute(status->{secondStarted.countDown();
return consumeDay(repository,repository.lockLot(1L),inbound.plusHours(2));}));
assertThat(first.get(5,TimeUnit.SECONDS).add(second.get(5,TimeUnit.SECONDS))).isEqualByComparingTo("1.5");
assertThat(repository.lot(1L).getChargedDays()).isEqualTo(1);assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("14");
} finally {workers.shutdownNow();}
}
/** 锁内计算未收费期间并同时保存实际出库数量,模拟业务事务的持久化边界。 */
private BigDecimal consumeDay(StorageRentRepository repository,StorageRentLot lot,LocalDateTime at) {
var amount=StorageRentCalculator.returnPalletAmount(StorageRentCalculator.defaults(null),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getChargedDays());
lot.setChargedDays(StorageRentCalculator.days(lot.getInboundAt(),at));lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(BigDecimal.ONE));
repository.save(lot);return amount;
}
/** 有界同步并发测试,不依赖休眠制造时序。 */
private void await(CountDownLatch latch) {
try {if(!latch.await(5,TimeUnit.SECONDS))throw new IllegalStateException("并发测试等待超时");}
catch(InterruptedException interrupted) {Thread.currentThread().interrupt();throw new IllegalStateException(interrupted);}
}
}
@@ -45,6 +45,37 @@ class StorageRentReviewSourceTest {
verify(rents).lots(List.of(1L,2L,3L,4L,5L));
}
/** 测试单的旧比例费用只按原报价解释,当前整托口径和新价格不能改写既有金额。 */
@Test void explainsHistoricalReturnFractionUsingSavedPricing() throws Exception {
var rents=mock(StorageRentRepository.class);var json=new ObjectMapper().findAndRegisterModules();
var service=new StorageRentReviewService(rents,mock(OverseasManualFeeRepository.class),mock(OverseasFeeSourceService.class),
mock(OverseasFeeCurrencyConverter.class),mock(WalletCommandOutboxService.class),json);
var at=LocalDateTime.of(2026,11,2,10,0);
var allocation=new Allocation(1L,2L,new BigDecimal("9"),new BigDecimal("0.84375"),at,1);
var plan=new Plan(new Owner("10",null,"DISCARD_ORDER",3L,"CD3",null,null,"20"),List.of(allocation),at.plusHours(2),new BigDecimal("0.84"));
var rent=new StorageRentPreparation();rent.setId(90L);rent.setSourceFeeId(100L);rent.setWarehouseId("20");
rent.setExecutionNo("CD3");rent.setConsumedAt(at.plusHours(2));rent.setPlanJson(json.writeValueAsString(plan));
var fee=new OverseasFeeSource();fee.setSourceFeeId(100L);fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");fee.setCustomerId("10");
var original=lot(1L,"10","20","RETURN_ORDER","TH1");original.setRuleCode("RETURN");original.setOriginalQuantity(new BigDecimal("16"));original.setOriginalUnits(BigDecimal.ONE);
when(rents.forFees(List.of(100L))).thenReturn(List.of(rent));when(rents.lots(List.of(1L))).thenReturn(List.of(original));
service.describe(List.of(fee));
var calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_SHARE");assertThat(calculation.billingQuantity()).isEqualByComparingTo("0.5625");
assertThat(calculation.tiers().get(0).unitPrice()).isEqualByComparingTo("1.5");
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("0.84375");
assertThat(fee.getStorageRent().calculatedAmount()).isEqualByComparingTo("0.84");
// 新费用解释直接采用原快照的基数及阶梯,即使后来原批次信息改变也不重算。
var snapshot=new Calculation(1L,"RETURN_PALLET",BigDecimal.ONE,"PALLET",40,10,
StorageRentCalculator.breakdown(plan.pricing(),"RETURN",10,40,BigDecimal.ONE),new BigDecimal("50"));
rent.setPlanJson(json.writeValueAsString(new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),new BigDecimal("50"),plan.pricing(),List.of(snapshot))));
original.setOriginalUnits(new BigDecimal("99"));original.setOriginalQuantity(new BigDecimal("999"));
service.describe(List.of(fee));calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_PALLET");assertThat(calculation.billingQuantity()).isEqualByComparingTo("1");
assertThat(calculation.previouslyChargedDays()).isEqualTo(10);assertThat(calculation.tiers()).hasSize(2);
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("30");assertThat(calculation.tiers().get(1).amount()).isEqualByComparingTo("20");
}
/** 构造原入库业务批次,不从现有出库订单推断来源。 */
private StorageRentLot lot(long id,String customer,String warehouse,String type,String number) {
var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId(customer);lot.setWarehouseId(warehouse);
@@ -103,6 +103,52 @@ class StorageRentServiceTest {
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());verify(fees,never()).publishSettled(anyString(),anyString(),any(),any(),any());
}
/** 同托一次扫码多条商品只收一次,出库推进整托计费日,余货同一天出库为零租金。 */
@Test void partialReturnConsumesWholePalletPeriodAndRemainingBoxDoesNotPaySameDayAgain() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(
new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L),
new Allocation(51L,62L,new BigDecimal("5"),BigDecimal.ZERO,at.minusDays(10),10,0L)),at,new BigDecimal("15"));
share.setQuantity(new BigDecimal("4"));
var second=new StorageRentShare();second.setId(62L);second.setLotId(51L);second.setQuantity(new BigDecimal("6"));
when(repository.lockShare(62L)).thenReturn(second);
ready();assertThat(lot.getChargedDays()).isZero();service.consume("9",request);
assertThat(lot.getChargedDays()).isEqualTo(10);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("1");
var captured=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
verify(fees).createPendingStorageRent(anyString(),captured.capture(),any());
assertThat(captured.getValue().feeRemark()).contains("余一箱仍计一托", "第1–10计租日");
current=null;clearInvocations(wallet,converter);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,62L,BigDecimal.ONE,BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThat(service.prepare("9",request).ready()).isTrue();service.consume("9",request);
assertThat(lot.getRemainingQuantity()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
verifyNoInteractions(wallet,converter);
}
/** 原托另一箱先完成计费后,旧冻结必须重建,不能继续按旧整托金额再收费。 */
@Test void anotherReturnScanAdvancingCoveredDaysInvalidatesOriginalFreeze() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L)),at,new BigDecimal("15"));
ready();var first=current;
lot.setChargedDays(10L);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");verifyNoInteractions(fees);
var rebuilt=service.prepare("9",request);
assertThat(rebuilt.ready()).isTrue();assertThat(rebuilt.originalAmount()).isZero();
assertThat(first.getStatus()).isEqualTo("EXPIRED");assertThat(first.getReleaseEventId()).isNotBlank();
service.consume("9",request);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
assertThat(current.getOriginalAmount()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
}
/** 冻结未实际出库不代表整托已计费,取消预占后下次仍须计算原期间。 */
@Test void cancelledReturnPreparationDoesNotAdvanceChargedDays() {
var a=plan.allocations().get(0);lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),new BigDecimal("15"),a.inboundAt(),10,0L)),plan.calculatedAt(),new BigDecimal("15"));
ready();service.cancel("9",current.getId());
assertThat(lot.getChargedDays()).isZero();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");
verify(repository,never()).save(any(StorageRentLot.class));
}
/** 使用真实费用校验验证扣款回执只登记本地明细,重放不重复扣款或推送结算。 */
@ParameterizedTest @ValueSource(strings={"LASTLEG_ORDER","RETURN_ORDER","WAREHOUSE_TRANSFER","RELABEL_ORDER"})
void eachBusinessCreatesPendingRentBeforeApproval(String businessType) {
@@ -116,8 +116,8 @@ class StorageRentSourceResolverTest {
assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单");
}
/** 退货只计原托商品比例,四件从十件原托出库不再向上取一个整托。 */
@Test void returnSkuOutboundUsesOriginalPalletFractionAndCurrentReturnOrder(){
/** 退货四件从十件原托出库仍收整托,费用保留当前退货单及原托库龄。 */
@Test void returnSkuOutboundChargesWholePalletAndCurrentReturnOrder(){
var task=new OutboundPdaTask();task.setOutboundOrderId(41L);task.setCustomerId("101");task.setStatus("PENDING");
var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("RETURN_OUTBOUND");
var line=new OutboundPalletItem();line.setOutboundOrderId(41L);line.setCustomerId("101");line.setCartonMarks("BOX-1");line.setSkuCode("SKU");line.setReturnSkuQuantity(4L);line.setInboundCode("RET-1");
@@ -127,8 +127,26 @@ class StorageRentSourceResolverTest {
when(repository.task("9","TASK-1")).thenReturn(task);when(repository.outboundItem(12L)).thenReturn(line);when(repository.outbound(41L)).thenReturn(outbound);
when(repository.returnOrder("101","RET-1")).thenReturn(ret);when(repository.carton("BOX-1")).thenReturn(carton);when(repository.shares("101","9","BOX-1","SKU")).thenReturn(List.of(share));when(repository.lockLot(52L)).thenReturn(lot(52L,"RETURN",2,BigDecimal.TEN));
var r=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"SKU",4L);
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("1.20");
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("3.00");
assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");
assertThat(plan.allocations().get(0).previouslyChargedDays()).isZero();
assertThat(plan.calculations()).hasSize(1);assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("1");
assertThat(plan.calculations().get(0).tiers()).containsExactly(new CalculationTier(1,2,2,new BigDecimal("1.5"),new BigDecimal("3.0")));
// 同一天已生成整托租金后另扫余货只扣数量,跨日再按原阶梯补新增整托租金。
var original=repository.lockLot(52L);original.setChargedDays(2L);
assertThat(service.resolve("9",r,at).amount()).isZero();
var continued=service.resolve("9",r,at.plusDays(1));assertThat(continued.amount()).isEqualByComparingTo("1.50");
assertThat(continued.calculations().get(0).tiers()).containsExactly(new CalculationTier(3,3,1,new BigDecimal("1.5"),new BigDecimal("1.5")));
// 同一原托混多个 SKU 只收一次;若来自两个原托则分别各收一托。
original.setChargedDays(0L);line.setReturnSkuQuantity(null);line.setSkuCode(null);
var other=new StorageRentShare();other.setId(63L);other.setLotId(52L);other.setQuantity(BigDecimal.ONE);
share.setQuantity(new BigDecimal("9"));when(repository.shares("101","9","BOX-1",null)).thenReturn(List.of(share,other));
var mixed=service.resolve("9",r,at);assertThat(mixed.amount()).isEqualByComparingTo("3.00");
assertThat(mixed.calculations()).hasSize(1);
assertThat(mixed.allocations()).extracting(Allocation::amount).containsExactly(new BigDecimal("3.0"),BigDecimal.ZERO);
other.setLotId(53L);when(repository.lockLot(53L)).thenReturn(lot(53L,"RETURN",2,BigDecimal.ONE));
assertThat(service.resolve("9",r,at).amount()).isEqualByComparingTo("6.00");
line.setReturnSkuQuantity(4L);line.setSkuCode("SKU");
var wrong=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"OTHER",4L);
assertThatThrownBy(()->service.resolve("9",wrong,at)).hasMessageContaining("原出库单不一致");
}
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount", "originAddress", "destinationAddress"), fields);
}
}
@@ -25,7 +25,7 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
var request = new UsTruckOrderRequests.Create(vehicle, "LA", "123 Main St", "ONT", "456 Oak St", 1,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
@@ -44,10 +44,20 @@ class UsTruckOrderControllerTest {
}
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
void platformCannotCancelEvenWithStatusPermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
var error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantCancellationUsesTrustedCustomer() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-customer", "客户取消需求");
merchant.changeStatus(user(100L, "MERCHANT", Set.of()), "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
assertThrows(ResponseStatusException.class,
() -> merchant.changeStatus(user(0L, "PLATFORM", Set.of()), "TK1", request));
}
@Test
@@ -81,6 +81,26 @@ class UsTruckOrderServiceTest {
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
}
/** 客户取消待审订单须释放预算,同一请求重试不得重复释放。 */
@Test
void pendingCustomerCancellationReleasesBudgetOnceAndReplays() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-pending", "客户取消需求");
var result = service.changeStatus("100", "TK1", "operator-7", request);
assertEquals("CANCELLED", result.summary().status());
service.changeStatus("100", "TK1", "operator-7", request);
verify(orderBudget).releaseAfterReview("100", "trucking", "TK1");
assertEquals(1L, order.getVersion());
}
/** 审核后已接入排车,不能通过取消原单绕过运输任务约束。 */
@Test
void customerCannotCancelApprovedTransportOrder() {
order.setStatus(UsTruckOrderService.PENDING_DISPATCH);
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-approved", "客户取消需求")));
org.mockito.Mockito.verifyNoInteractions(orderBudget);
}
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
@@ -68,14 +68,20 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
@Test void reviewReleaseImmediatelyReleasesFrozenBudget() {
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
row.setKind(kind);
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
when(repository.lockOrder("9","procurement","CG-1")).thenReturn(row);
when(repository.lockOrder("9",kind,"CG-1")).thenReturn(row);
when(wallet.publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L))).thenReturn("release");
service.releaseAfterReview("9","procurement","CG-1");
service.releaseAfterReview("9",kind,"CG-1");
assertThat(row.getReleaseRequested()).isTrue();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
verify(repository).save(row);
service.releaseAfterReview("9",kind,"CG-1");
verify(wallet,times(1)).publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L));
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));
assertThat(row.getStatus()).isEqualTo("RELEASED");
}
/** 已绑定订单的预算释放后仍允许原键恢复原单,但客户、金额与币种不能借重放改写。 */
@ParameterizedTest @ValueSource(strings={"RELEASE_PENDING","RELEASED","RELEASE_FAILED"})