This commit is contained in:
2026-10-29 13:36:38 +08:00
parent 353c86f4cf
commit 1b27d7e6c2
60 changed files with 1208 additions and 60 deletions
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
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.*;
/** 执行真实 MyBatis SQL,验证仅已确认待发换标箱进入可选来源。 */
class CargoDiscardRelabelMapperTest {
@Test
void selectsConfirmedHeldBoxesAndExcludesClaimedOrShippedSources() {
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,overseas_warehouse_id VARCHAR(20),hold_packages BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),hold_packages BIGINT,inbound_code VARCHAR(80))");
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR(20),relabel_no VARCHAR(80),overseas_warehouse_id VARCHAR(20),status VARCHAR(40),fulfillment_status VARCHAR(40),customer_confirmed_at TIMESTAMP,outbound_order_no VARCHAR(80),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(20),source_inventory_position_id BIGINT,return_carton_id BIGINT,notified_package_count INT)");
db.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(20),carton_id BIGINT,source_type VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_inventory_allocation(customer_id VARCHAR(20),inventory_position_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,carton_mark VARCHAR(80),customer_carton_mark VARCHAR(80))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT)");
db.execute("INSERT INTO ll_inventory_position VALUES(10,'C',20,'W',2,30)");
db.execute("INSERT INTO ll_inventory_balance VALUES(20,'C',2,'ZY-1')");
db.execute("INSERT INTO ll_relabel_application VALUES(1,'C','TB-1','W','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH',CURRENT_TIMESTAMP,NULL,NULL),(2,'C','TB-2','W','READY_FOR_OUTBOUND','PRE_DISPATCH_CREATED',CURRENT_TIMESTAMP,NULL,NULL)");
db.execute("INSERT INTO ll_relabel_item VALUES(101,1,'C',10,NULL,1),(102,1,'C',10,NULL,1),(201,2,'C',10,NULL,1)");
db.execute("INSERT INTO ll_cargo_discard_carton VALUES('C',101,'RELABEL_DISCARDED')");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(80)");
db.execute("UPDATE ll_relabel_item SET source_inbound_order_id=50,carton_mark='CUSTOMER-BOX-1' WHERE id=102");
db.execute("INSERT INTO ll_warehouse_transfer_order VALUES(60,'C',50),(61,'OTHER',50),(62,'C',51)");
db.execute("INSERT INTO ll_warehouse_transfer_carton VALUES(70,'C',60,'BX-SYSTEM-1','CUSTOMER-BOX-1'),"
+ "(71,'OTHER',61,'BX-OTHER-CUSTOMER','CUSTOMER-BOX-1'),(72,'C',62,'BX-OTHER-INBOUND','CUSTOMER-BOX-1')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(CargoDiscardRelabelMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
var mapper = session.getMapper(CargoDiscardRelabelMapper.class);
assertEquals(java.util.List.of(102L), mapper.cartons("C", 10L).stream().map(i -> i.getId()).toList());
assertEquals(java.util.List.of(10L), mapper.positions("C", "ZY-1").stream().map(p -> p.getId()).toList());
assertTrue(mapper.lockEligible("C", 102L) != null);
assertNull(mapper.lockEligible("C", 101L));
assertTrue(mapper.cartons("OTHER", 10L).isEmpty());
assertTrue(mapper.cartons("C", 999L).isEmpty());
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertTrue(mapper.systemCartonMarks("OTHER", 102L).isEmpty());
db.update("UPDATE ll_relabel_item SET carton_mark='BX-SYSTEM-1' WHERE id=102");
session.clearCache();
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertEquals(1, mapper.refresh("C", "TB-1"));
}
}
}
@@ -210,8 +210,11 @@ class PdaCargoDiscardServiceTest {
noWrites(); verifyNoInteractions(photoService);
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
void looseTaskRequiresAllScansEvenWithBulkFlagAndReplays(boolean bulk) {
looseTask();
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(List.of(), bulk)));
noWrites(); verifyNoInteractions(photoService);
var request = looseRequest(List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request);
@@ -242,8 +245,9 @@ class PdaCargoDiscardServiceTest {
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
@ParameterizedTest @ValueSource(booleans = {false, true})
void boundPalletExposesSelectedBoxesAndRequiresTheirScan(boolean partial) {
looseTask(); item.setCartonScanRequired(partial);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 废弃实箱扫码身份保持系统箱唛,商品仍使用换标后的 SKU。 */
class CargoDiscardRelabelStockServiceTest {
@Test
void keepsSystemCartonMarkInsteadOfCustomerOrFbaLabel() {
var repository = mock(CargoDiscardRelabelRepository.class);
var sources = mock(RelabelDispatchSourcePort.class);
var service = new CargoDiscardRelabelStockService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "sources", sources);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var position = new InventoryPosition(); position.setId(10L); position.setPalletId(30L);
position.setOverseasWarehouseId("W"); position.setHoldPackages(1L);
var item = new RelabelItem(); item.setId(102L); item.setCartonMark("CUSTOMER-BOX-1");
item.setFbaCartonMark("FBA-BOX-1"); item.setSkuCode("OLD-SKU"); item.setNewSkuCode("NEW-SKU");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":5}]");
when(repository.cartons("C", 10L)).thenReturn(List.of(item));
var bound = new RelabelDispatchSourcePort.BoundSource(1L, "TB-1", 102L, "C", "W", 30L,
10L, 20L, 20L, 50L, "ZY-1", "ZY-1", 1, 1, 5L, "hash", List.of());
when(sources.snapshot("W", List.of(30L))).thenReturn(new RelabelDispatchSourcePort.Snapshot(
List.of(), List.of(bound), List.of(), "version", true, List.of()));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1"));
var box = service.cartons("C", position).get(0);
assertEquals("BX-SYSTEM-1", box.getCartonMark());
assertEquals("NEW-SKU", box.getSkuCode());
assertTrue(box.getSkuItemsJson().contains("NEW-SKU"));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1", "BX-SYSTEM-2"));
assertThrows(ResponseStatusException.class, () -> service.cartons("C", position));
// 普通非转运来源沿用原我方箱唛,也不采用 FBA 标签。
item.setCartonMark("BX-NORMAL");
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of());
assertEquals("BX-NORMAL", service.cartons("C", position).get(0).getCartonMark());
}
}
@@ -32,6 +32,7 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "stock", stock);
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
@@ -46,6 +46,9 @@ class DriverPersistenceTest {
configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class);
configuration.addMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class);
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(trip_id BIGINT,dispatch_no VARCHAR(64))");
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds);
factory.setConfiguration(configuration);
@@ -53,12 +56,25 @@ class DriverPersistenceTest {
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "availability", new com.baiyue.lastleg.fleet.trip.DriverAvailabilityRepository(session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class)));
var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy();
}
@Test void dispatchCandidatesExcludeOtherTripsBeforePagingButKeepOwnDriver() {
var own = service.create("operator", draft("本车司机"));
var occupied = service.create("operator", draft("其他车司机"));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,1,'TR1')", Long.parseLong(own.id()));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,2,'TR2')", Long.parseLong(occupied.id()));
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'PC1')");
var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id());
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null)).total()).isEqualTo(2);
}
@Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
@@ -58,6 +58,7 @@ class ProcurementWorkflowAuthorizationTest {
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);
assertThatThrownBy(()->merchant.reject(user,"CG-1",new Rejection(0,"r","照片不符合要求"))).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));
@@ -51,6 +51,27 @@ class ProcurementWorkflowPersistenceTest {
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 rejectionRequiresNewPhotosWithoutChangingStock() {
tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L))));
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
assertThatThrownBy(()->tx.execute(t->service.reject("9","CG-1","8",new Rejection(2,"empty"," ")))).hasMessageContaining("原因");
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰");
var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue();
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
long receipt=rejected.receipts().get(0).id();
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
file(105L,11L,"9","image/png");
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED");
}
@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));
@@ -60,6 +60,7 @@ class RelabelDispatchSourcePersistenceTest {
source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT,
source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80))
""");
jdbc.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(40),carton_id BIGINT,source_type VARCHAR(32))");
jdbc.execute("""
CREATE TABLE ll_inventory_position (
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
@@ -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"), fields);
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
}
}
@@ -25,8 +25,8 @@ 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, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 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());
}
@@ -91,7 +91,7 @@ class UsTruckOrderServiceTest {
assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
assertEquals(request.pickupAt(), detail.summary().pickupAt());
assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
assertEquals(0, detail.summary().palletCount());
assertEquals(3, detail.summary().palletCount());
assertTrue(detail.pallets().isEmpty());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
}
@@ -108,7 +108,7 @@ class UsTruckOrderServiceTest {
@Test
void reversedTimesAreRejectedBeforeCreatingAnOrder() {
var request = create("PALLET_30", "reversed");
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.palletCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid));
}
@@ -169,7 +169,7 @@ class UsTruckOrderServiceTest {
private UsTruckOrderRequests.Create create(String vehicleType, String key) {
return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null,
0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
3, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.TEN, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key);
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.security.AuthenticatedUser;
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.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 预算入口不允许跨业务借用权限或由平台身份指定客户资金。 */
class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
@Test void procurementPermissionCannotFreezeTruckAndOnlyUsesAuthenticatedCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var user=login(9,"MERCHANT","merchant:cross-border:procurement:create");
var request=new MerchantOrderBudgetController.Prepare(BigDecimal.TEN,"USD");
controller.prepare(user,"procurement","key",request);
verify(context.getBean(OrderBudgetService.class)).prepare("9","procurement","key",BigDecimal.TEN,"USD");
assertThatThrownBy(()->controller.prepare(user,"trucking","key",request)).isInstanceOf(AccessDeniedException.class);
}
@Test void readPermissionCannotReserveAndPlatformCannotImpersonateCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
assertThatThrownBy(()->controller.read(reader,"procurement","key")).isInstanceOf(AccessDeniedException.class);
var platform=login(9,"PLATFORM","merchant:cross-border:procurement:create");
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.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;
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
assertThat(repository.lock("10","procurement","key1")).isNull();
assertThat(repository.lockId(1,"10")).isNull();
var loaded = repository.lock("9","procurement","key1");
loaded.setOrderNo("CG1");repository.save(loaded);
assertThat(repository.lockOrder("9","procurement","CG1").getOriginalAmount()).isEqualByComparingTo("100");
assertThat(repository.lockOrder("10","procurement","CG1")).isNull();
}
@Test void selectsExpiredTerminalAndFailedReleaseButKeepsLiveOrders() {
var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now().minusMinutes(16));repository.insert(expired);
var terminal = row(2,"FROZEN");terminal.setOrderNo("CG2");repository.insert(terminal);
jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES('9','CG2','COMPLETED')");
var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now().minusMinutes(2));repository.insert(failed);
repository.insert(row(4,"FROZEN"));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
}
private OrderBudget row(long id,String status) {
var row=new OrderBudget();row.setId(id);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key"+id);
row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));
row.setStatus(status);row.setCommandVersion(1L);row.setReleaseRequested(false);
row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(LocalDateTime.now());return row;
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
/** 验证资金不足、金额篡改、过期在途回执及客户隔离不能绕过建单门禁。 */
class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderBudget row;
@BeforeEach void setup() {
row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row);
}
@Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() {
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中");
service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足");
when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry");
assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry");
}
@Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化");
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
}
@Test void expiryWaitsForLateFreezeThenReleases() {
when(repository.releasable()).thenReturn(List.of(row));service.releaseFinished();assertThat(row.getReleaseRequested()).isTrue();
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));assertThat(row.getStatus()).isEqualTo("RELEASED");
}
@Test void failedReleaseRetriesWithANewVersionAndBlocksSubmission() {
row.setStatus("RELEASE_FAILED");row.setReleaseRequested(true);
when(repository.releasable()).thenReturn(List.of(row));
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release-retry");
service.releaseFinished();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(row.getCommandEventId()).isEqualTo("release-retry");
assertThat(service.read("9","procurement","key").usable()).isFalse();
}
@Test void expiredFrozenBudgetIsNeverReportedUsable() {
row.setStatus("FROZEN");row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16));
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {
return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null);
}
}