提交
This commit is contained in:
+4
-4
@@ -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", "验收:库存不足应失败")));
|
||||
|
||||
+36
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
+16
-58
@@ -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());
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
@@ -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("未发车");
|
||||
|
||||
+19
-5
@@ -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() {
|
||||
|
||||
+30
-6
@@ -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();
|
||||
}
|
||||
|
||||
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
|
||||
|
||||
+70
@@ -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);}
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
|
||||
+46
@@ -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) {
|
||||
|
||||
+21
-3
@@ -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("原出库单不一致");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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());
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+20
@@ -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");
|
||||
|
||||
+9
-3
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user