费用提交

This commit is contained in:
2026-10-27 18:27:56 +08:00
parent 423e40e6aa
commit f418f9a05f
56 changed files with 649 additions and 7898 deletions
@@ -23,8 +23,10 @@ class OverseasProcurementSchemaContractTest {
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee"));
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
assertTrue(sql.contains("change_detail JSON NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
/** 防止旧采购表引用回流,以及删表清单误伤共用财务或新流程数据。 */
class ProcurementLegacyRetirementContractTest {
private final Set<String> retained=Set.of("ll_overseas_procurement_order","ll_overseas_procurement_status_history","ll_overseas_procurement_file",
"ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command");
private String script() throws Exception { return Files.readString(Path.of("../deploy/sql/overseas-procurement-legacy-tables-retire.sql")); }
private Set<String> dropped(String sql) {
var match=Pattern.compile("DROP TABLE IF EXISTS\\s+([^;]+);",Pattern.CASE_INSENSITIVE).matcher(sql);
assertThat(match.find()).isTrue();
var result=new HashSet<String>();for(String table:match.group(1).split(","))result.add(table.trim());
assertThat(match.find()).isFalse();return result;
}
@Test void onlyTwentyOneRetiredProcurementTablesCanBeDroppedAndNoLiveSourceReferencesThem() throws Exception {
var retired=dropped(script());assertThat(retired).hasSize(21).doesNotContainAnyElementsOf(retained);
assertThat(retired).allMatch(t->t.equals("ll_overseas_procurement_fee") || t.startsWith("ll_procurement_") && !t.startsWith("ll_procurement_workflow_"));
try(var files=Files.walk(Path.of("src/main/java"))) {
for(var file:files.filter(f->f.toString().endsWith(".java")).toList()) {
var source=Files.readString(file);
for(String table:retired)assertThat(Pattern.compile("\\b"+table+"\\b").matcher(source).find()).as(file+" must not reference "+table).isFalse();
}
}
for(String file:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
var sql=Files.readString(Path.of(file));
for(String table:retired) assertThat(sql).doesNotContain("CREATE TABLE "+table+" (","CREATE TABLE IF NOT EXISTS "+table+" (");
for(String table:retained) assertThat(sql).containsPattern("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\(");
}
}
@Test void destructiveScriptChecksFinancialEvidenceAndContainsNoSharedTableMutation() throws Exception {
var sql=script();
assertThat(sql.indexOf("SIGNAL SQLSTATE")).isLessThan(sql.indexOf("DROP TABLE IF EXISTS"));
assertThat(sql).contains("procurement-six-tables-20261027","@procurement_retirement_financial_rows<>0","aggregate_type='PROCUREMENT_FEE'","$.payload.businessType","<>6");
assertThat(sql).doesNotContain("FOREIGN_KEY_CHECKS=0","TRUNCATE TABLE","DELETE FROM","UPDATE ll_","ALTER TABLE");
for(String shared:List.of("ll_finance_fee_source","ll_finance_fee_funding","ll_event_outbox","ll_wallet_event_inbox","ll_inventory"))assertThat(dropped(sql)).doesNotContain(shared);
}
@Test void retiredOperationControllersAndOpenApiPathsAreAbsent() throws Exception {
for(String name:List.of("Lifecycle","Physical","Transport","Charge","Control")) {
assertThat(Files.exists(Path.of("src/main/java/com/baiyue/lastleg/procurement/controller/Procurement"+name+"Controller.java"))).isFalse();
}
var spec=Files.readString(Path.of("src/main/resources/openapi/procurement-fulfillment-v1.yaml"));
for(String suffix:List.of("/purchase:","/packing:","/labels:","/charges:","/exception-close:"))assertThat(spec).doesNotContain(suffix);
assertThat(spec).contains("/files/{category}:","/files/{fileId}/content:","ProcurementCustomerResponse:","ProcurementPlatformResponse:");
}
}
@@ -50,10 +50,7 @@ class ProcurementRequestBoundaryTest {
var json=new ObjectMapper().findAndRegisterModules();
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Purchase.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget.class,
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Warehouse.class)) {
for(var type:List.of(OverseasProcurementRequests.Review.class, OverseasProcurementRequests.Update.class)) {
assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString()));
}
}
@@ -67,10 +64,10 @@ class ProcurementRequestBoundaryTest {
}
@Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception {
var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee();
fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");
fee.setVoucherFileId(2L);fee.setVoucherFileName("private.pdf");fee.setWalletErrorMessage("database error");fee.setRemark("internal note");fee.setCreatedBy("operator");fee.setWalletLedgerId("private-ledger");
var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.from(fee,null,false));
assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
var fee=new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setSourceFeeId(1L);fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");
fee.setPaymentVoucherFileIds(List.of("private.pdf"));fee.setWalletErrorMessage("database error");fee.setReviewReason("internal note");fee.setCreatedBy("operator");
var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.fromManual(fee));
assertThat(output).contains("FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
}
}
@@ -1,145 +0,0 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService;
import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.time.LocalDateTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementLifecycleAuthorizationTest {
private final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class);
private final ProcurementLifecycleController controller=context.getBean(ProcurementLifecycleController.class);
private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
@Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);}
@Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);}
@Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);}
@Bean ProcurementPhysicalController physicalController(ProcurementPhysicalService service){return new ProcurementPhysicalController(service);}
// Register the mock directly: its legacy @Resource fields must not be autowired in this controller-only test.
@Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock(){
return factory -> factory.registerSingleton("orders",mock(OverseasProcurementService.class));
}
@Bean ProcurementFileController files(OverseasProcurementService service){return new ProcurementFileController(service);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementTransportService transportService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);}
@Bean ProcurementTransportController transport(com.baiyue.lastleg.procurement.service.ProcurementTransportService service){return new ProcurementTransportController(service);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementChargeService chargeService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustmentService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);}
@Bean ProcurementChargeController charges(com.baiyue.lastleg.procurement.service.ProcurementChargeService service,com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustments){return new ProcurementChargeController(service,adjustments);}
@Bean com.baiyue.lastleg.procurement.service.ProcurementControlService controlService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementControlService.class);}
@Bean ProcurementControlController controls(com.baiyue.lastleg.procurement.service.ProcurementControlService service){return new ProcurementControlController(service);}
}
@AfterEach void cleanup(){SecurityContextHolder.clearContext();context.close();}
@Test void listPermissionDoesNotGrantBuyingOrCancelling() {
var user=login(0,"PLATFORM","list");controller.detail(user,"9","PO-1");
verify(service).read("9","PO-1");
assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.cancel(user,"9","PO-1",new Command(0L,"key","reason"))).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void buyerCannotAuthorizeItsOwnBudgetWithoutReviewPermission() {
var user=login(0,"PLATFORM","buy");var purchase=purchase();controller.purchase(user,"9","PO-1",purchase);
verify(service).recordPurchase("9","PO-1","7",purchase);
assertThatThrownBy(()->controller.budget(user,"9","PO-1",new Budget(0L,"budget",BigDecimal.TEN,"CNY",1,"reason")))
.isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void customerIdentityCannotUsePlatformActionEvenWithInjectedAuthority() {
var user=login(9,"MERCHANT","buy");
assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(service);
}
@Test void overseasReceiverCannotPerformDomesticReceivingOrApproveDiscrepancy() {
var physical=context.getBean(ProcurementPhysicalController.class);var implementation=context.getBean(ProcurementPhysicalService.class);
var user=login(0,"PLATFORM","receive-overseas");
var receipt=new ProcurementPhysicalRequests.Receipt(0L,"r",ProcurementPhysicalRequests.Leg.OVERSEAS,91L,null,1,0,0,LocalDateTime.now().minusHours(1),"actual");
physical.overseas(user,"9","PO-1",receipt);verify(implementation).receive("9","PO-1","7",receipt);
assertThatThrownBy(()->physical.domestic(user,"9","PO-1",receipt)).isInstanceOf(AccessDeniedException.class);
var decision=new ProcurementPhysicalRequests.Disposition(0L,"d",ProcurementPhysicalRequests.Leg.OVERSEAS,null,null,"SHORTAGE",1,"ACCEPT_SHORTAGE",1,"confirmed");
assertThatThrownBy(()->physical.dispose(user,"9","PO-1",decision)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(implementation);
}
@Test void labelUploadDoesNotGrantFinancialVoucherOrBudgetUpload() {
var files=context.getBean(ProcurementFileController.class);var orders=context.getBean(OverseasProcurementService.class);
var user=login(0,"PLATFORM","label");var file=new org.springframework.mock.web.MockMultipartFile("file","label.pdf","application/pdf","%PDF-1.4".getBytes());
files.upload(user,"9","PO-1","LABEL",file);verify(orders).uploadFile("9","PO-1","7","LABEL",file);
assertThatThrownBy(()->files.upload(user,"9","PO-1","VOUCHER",file)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->files.upload(user,"9","PO-1","BUDGET",file)).isInstanceOf(ResponseStatusException.class);
verifyNoMoreInteractions(orders);
}
@Test void overseasShippingDoesNotGrantDomesticHandoffOrEstimatePermission() {
var transport=context.getBean(ProcurementTransportController.class);
var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);
var user=login(0,"PLATFORM","ship-overseas");
var request=new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Ship(0L,"ship",ProcurementPhysicalRequests.Leg.OVERSEAS,1,java.util.List.of(1L),LocalDateTime.now(),"actual");
transport.overseas(user,"9","PO-1",request);verify(service).ship("9","PO-1","7",request);
assertThatThrownBy(()->transport.customer(user,"9","PO-1",request)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->transport.estimate(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
@Test void costConfirmationDoesNotGrantFinancialCorrectionAndRetryMatchesFeePurpose() {
var charges=context.getBean(ProcurementChargeController.class);
var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);
var user=login(0,"PLATFORM","charge");
assertThatThrownBy(()->charges.additional(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.correct(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.refund(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->charges.replaceFailed(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class);
when(service.retryPermission("9","PO-1",1)).thenReturn("platform:overseas-business:purchase:finance-adjust");
var command=new Command(0L,"retry","retry confirmed failure");
assertThatThrownBy(()->charges.retry(user,"9","PO-1",1,command)).isInstanceOf(ResponseStatusException.class);
verify(service,never()).retry(anyString(),anyString(),anyString(),anyLong(),any());
when(service.retryPermission("9","PO-1",2)).thenReturn("platform:overseas-business:purchase:charge");
charges.retry(user,"9","PO-1",2,command);verify(service).retry("9","PO-1","7",2,command);
}
@Test void financeMayReplaceFailedFeeButCustomerIdentityStillCannotUseThePlatformBoundary() {
var charges=context.getBean(ProcurementChargeController.class);var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);
var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct(0L,"replacement",BigDecimal.TEN,1,"replacement proof");
var user=login(0,"PLATFORM","finance-adjust");charges.replaceFailed(user,"9","PO-1",1,request);verify(adjustments).replaceFailed("9","PO-1","7",1,request);
var customer=login(9,"MERCHANT","finance-adjust");assertThatThrownBy(()->charges.replaceFailed(customer,"9","PO-1",1,request)).isInstanceOf(ResponseStatusException.class);verifyNoMoreInteractions(adjustments);
}
@Test void financialAdditionalUsesPlatformOnlyFinancialBoundary() {
var charges=context.getBean(ProcurementChargeController.class);
var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);
var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional(0L,"additional","Storage",BigDecimal.TEN,"CNY",java.time.LocalDate.now(),1,"invoice");
var user=login(0,"PLATFORM","finance-adjust");charges.additional(user,"9","PO-1",request);
verify(adjustments).additional("9","PO-1","7",request);
var customer=login(9,"MERCHANT","finance-adjust");
assertThatThrownBy(()->charges.additional(customer,"9","PO-1",request)).isInstanceOf(ResponseStatusException.class);
verifyNoMoreInteractions(adjustments);
}
@Test void receivingPermissionCannotCloseOrderAndManagerCannotReleaseFunds() {
var controls=context.getBean(ProcurementControlController.class);
var receiver=login(0,"PLATFORM","receive-overseas");
assertThatThrownBy(()->controls.close(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controls.warehouse(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class);
var manager=login(0,"PLATFORM","manage");var charges=context.getBean(ProcurementChargeController.class);
assertThatThrownBy(()->charges.releaseEstimate(manager,"9","PO-1",1,new Command(0L,"release","close order"))).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String suffix) {
String permission="platform:overseas-business:purchase:"+suffix;
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",java.util.List.of(new SimpleGrantedAuthority(permission))));
return user;
}
private Purchase purchase(){return new Purchase(0L,"purchase","Sample",null,1,BigDecimal.TEN,"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(1),1,"actual purchase");}
}
@@ -7,7 +7,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
@@ -43,10 +42,8 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@Mock WalletCommandOutboxService walletCommands;
@Mock OverseasFeeSourceService feeSources;
@@ -58,15 +55,12 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
ReflectionTestUtils.setField(service, "directionRepository", directionRepository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
}
@Test
@@ -1,260 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.*;
import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementChargePersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;OverseasProcurementFeeMapper fees;
ProcurementFinanceService finance;ProcurementLifecycleService lifecycle;ProcurementChargeService charges;ProcurementAdjustmentService adjustments;
OverseasFeeCurrencyConverter converter;WalletCommandOutboxService commands;long estimateId;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_overseas_procurement_fee","ll_procurement_wallet_attempt","ll_procurement_fee_correction","ll_procurement_fee_correction_item","ll_procurement_budget_authorization")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))");
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementFeeMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);fees=session.getMapper(OverseasProcurementFeeMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_CHARGES");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,domestic_received_at) VALUES(11,'9',?)",LocalDateTime.now().minusHours(1));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')");
converter=mock(OverseasFeeCurrencyConverter.class);when(converter.convert(anyString(),any(),any())).thenAnswer(i->{String currency=i.getArgument(0);BigDecimal amount=i.getArgument(1);BigDecimal rate="USD".equals(currency)?new BigDecimal("7.123456"):BigDecimal.ONE;return new OverseasFeeCurrencyConverter.Conversion(currency,amount,rate,"2026-09",amount.multiply(rate).setScale(2,java.math.RoundingMode.HALF_UP));});
var sources=mock(OverseasFeeSourceService.class);AtomicLong ids=new AtomicLong(900);
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenAnswer(i->{var s=new OverseasFeeSource();s.setSourceFeeId(ids.incrementAndGet());return s;});
commands=mock(WalletCommandOutboxService.class);AtomicLong events=new AtomicLong();
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(finance, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(finance, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json);
charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(adjustments, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free")));
estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId();
}
@Test void allCategoriesAreExplicitAndReconfirmationReferencesExistingMoney() {
var request=request("100","5");var first=tx.execute(s->charges.confirm("9","PO-1","7",request));
assertThat(first.confirmedAt()).isNotNull();assertThat(first.status()).isEqualTo("WAIT_CUSTOMER_SHIPMENT");
assertThat(finance.all(11)).hasSize(5);assertThat(finance.all(11)).noneMatch(f->"OTHER".equals(f.getPurpose()));
assertThatThrownBy(()->finance.requireShipmentReady(11)).hasMessageContaining("未决资金");
var international=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var domestic=current(ProcurementFeePurpose.DOMESTIC_FREIGHT);settle(international);settle(domestic);
finance.requireShipmentReady(11);int count=countAttempts();
var again=new Confirm(version(),"reconfirm",new Selection(international.getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(domestic.getId(),null),"NOT_APPLICABLE",List.of(),"same expenses after repack");
tx.execute(s->charges.confirm("9","PO-1","7",again));assertThat(countAttempts()).isEqualTo(count);
tx.execute(s->charges.confirm("9","PO-1","7",request));assertThat(countAttempts()).isEqualTo(count);
}
@Test void missingDeterminationWrongPurposeAndUnreceivedOrdersCannotConfirm() {
var bad=new Confirm(0L,"missing",null,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"missing international");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",bad))).hasMessageContaining("每项费用");
var wrong=new Confirm(0L,"wrong",new Selection(estimateId,null),selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"estimate is not actual");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",wrong))).hasMessageContaining("该用途");
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL WHERE order_id=11");
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")))).hasMessageContaining("国内收货完成");
assertThat(countAttempts()).isZero();
}
@Test void omittedExistingOtherChargeCannotDisappearFromReconfirmation() {
var other=new Other("HANDLING","Handling",selection("10"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"first",selection("0"),selection("0"),selection("0"),"ITEMIZED",List.of(other),"actual")));
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"omit-other",new Selection(current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT).getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(current(ProcurementFeePurpose.DOMESTIC_FREIGHT).getId(),null),"NOT_APPLICABLE",List.of(),"omit")))).hasMessageContaining("未列入");
assertThat(finance.all(11)).hasSize(6);
}
@Test void additionalExpenseUsesOwnBusinessDateFinancialPermissionAndImmutableReplay() {
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2));
tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")));
var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense");
var fee=tx.execute(s->adjustments.additional("9","PO-1","7",request));
assertThat(fee.getConfirmationOrigin()).isEqualTo("FINANCIAL");
assertThat(fee.getBusinessDate()).isEqualTo(LocalDate.of(2026,9,2));
assertThat(fee.getAmount()).isEqualByComparingTo("23.15");
assertThat(charges.retryPermission("9","PO-1",fee.getId())).endsWith(":finance-adjust");
assertThat(lifecycle.flow(11).get("charges_confirmed_at")).isNull();
assertThat(orders.selectById(11).getStatus()).isEqualTo("WAIT_CHARGES");
int attempts=countAttempts();
assertThat(tx.execute(s->adjustments.additional("9","PO-1","7",request)).getId()).isEqualTo(fee.getId());
assertThat(countAttempts()).isEqualTo(attempts);
assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")))).hasMessageContaining("首次核费后");
settle(fee);finance.requireShipmentReady(11);
}
@Test void additionalExpenseRequiresPurchaseAndCannotEmitOutOfRangeMoney() {
var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense");
assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",request))).hasMessageContaining("已发生购买");
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2));
var excessive=new Additional(version(),"large","Storage",new BigDecimal("99999999999999.99"),"USD",LocalDate.of(2026,9,2),1001,"large");
assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",excessive))).hasMessageContaining("超出支持范围");
assertThat(countAttempts()).isZero();assertThat(finance.all(11)).hasSize(2);
}
@Test void target100To120To130To110ChargesOnlyDifferencesAndKeepsOriginal() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var first=correct(root,"120","first");assertThat(first.differenceAmount()).isEqualByComparingTo("20");settle(byNo(first.feeNos().get(0)));
var second=correct(root,"130","second");assertThat(second.differenceAmount()).isEqualByComparingTo("10");settle(byNo(second.feeNos().get(0)));
var third=correct(root,"110","third");assertThat(third.differenceAmount()).isEqualByComparingTo("-20");
var refund=byNo(third.feeNos().get(0));assertThat(refund.getPurpose()).isEqualTo("REFUND");assertThat(refund.getAmount()).isEqualByComparingTo("20");
assertThatThrownBy(()->correct(root,"115","pending")).hasMessageContaining("未完成");complete(refund,"REFUND");
assertThat(finance.readyChain(orders.findNo("9","PO-1"),fees.selectById(root.getId())).stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("130");
assertThat(fees.selectById(root.getId()).getAmount()).isEqualByComparingTo("100");
var zero=correct(root,"0","all-refund");assertThat(zero.previousNetAmount()).isEqualByComparingTo("110");assertThat(zero.feeNos()).hasSize(3);
assertThat(zero.feeNos().stream().map(this::byNo).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("110");
}
@Test void failedOldTargetMustBeTerminatedBeforeAnotherTargetAndCannotRetryLater() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var adjustment=byNo(correct(root,"120","first").feeNos().get(0));
tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(adjustment.getWalletCommandEventId(),"FREEZE",9,"PROCUREMENT_FEE",adjustment.getFeeNo(),"FAILED",null,adjustment.getAmount(),"INSUFFICIENT","insufficient",Instant.now(),null,null)));
assertThatThrownBy(()->correct(root,"130","blocked")).hasMessageContaining("未完成");
tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate","replace failed target")));
assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",adjustment.getId()))).hasMessageContaining("有效费用");
assertThat(correct(root,"130","new").differenceAmount()).isEqualByComparingTo("30");
}
@Test void originalUsdSnapshotIsUsedForTargetWithoutReloadingMonthlyRate() {
var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"original USD"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"original")));
var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);clearInvocations(converter);
var corrected=correct(root,"11","usd-correction");assertThat(corrected.exchangeRate()).isEqualByComparingTo("7.123456");assertThat(corrected.exchangeRateMonth()).isEqualTo("2026-09");
assertThat(corrected.targetAmount()).isEqualByComparingTo("78.36");assertThat(corrected.differenceAmount()).isEqualByComparingTo("7.13");
var delta=byNo(corrected.feeNos().get(0));assertThat(delta.getOriginalCurrency()).isEqualTo("CNY");assertThat(delta.getAmount()).isEqualByComparingTo("7.13");verifyNoInteractions(converter);
}
@Test void correctionReplayReturnsOriginalTargetAndDoesNotRegressCompletedFulfillment() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
var request=new Correct(version(),"same",new BigDecimal("120"),1001,"correction");
var original=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));int attempts=countAttempts();
var replay=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));assertThat(replay).isEqualTo(original);
assertThat(countAttempts()).isEqualTo(attempts);assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThatThrownBy(()->tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),"same",new BigDecimal("130"),1001,"correction")))).hasMessageContaining("相同操作幂等键");
}
@Test void terminatedUnpaidActualCanBeReplacedWithFrozenFxAndNewWalletIdentity() {
var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"USD original"));
tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"original-usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"all")));
var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var failure=failed(original,"FREEZE");tx.executeWithoutResult(s->finance.handle(failure));
assertThatThrownBy(()->replace(original,"11","too-soon")).hasMessageContaining("先终止");
tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-unpaid","correct actual")));
assertThatThrownBy(()->tx.execute(s->finance.confirmInternational("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT","actual","POSITIVE",new BigDecimal("11"),"USD",LocalDate.of(2026,9,1),1001L,"ordinary cannot replace")))).hasMessageContaining("财务权限");
assertThatThrownBy(()->replace(original,"9999999999999999.00","too-large")).hasMessageContaining("人民币金额超出支持范围");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();
clearInvocations(converter);var request=new Correct(version(),"replace-unpaid",new BigDecimal("11"),1001,"correct actual amount");
var result=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));var replacement=byNo(result.feeNos().get(0));
assertThat(result.previousNetAmount()).isZero();assertThat(result.targetAmount()).isEqualByComparingTo("78.36");
assertThat(replacement.getFeeVersion()).isEqualTo(original.getFeeVersion()+1);assertThat(replacement.getExchangeRate()).isEqualByComparingTo(original.getExchangeRate());
assertThat(replacement.getExchangeRateMonth()).isEqualTo("2026-09");assertThat(replacement.getWalletBusinessNo()).isNotEqualTo(original.getWalletBusinessNo());
assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getPurpose()).isEqualTo("INTERNATIONAL_FREIGHT");
assertThat(charges.retryPermission("9","PO-1",replacement.getId())).isEqualTo("platform:overseas-business:purchase:finance-adjust");
assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("TERMINATED");assertThat(fees.selectById(original.getId()).getAmount()).isEqualByComparingTo("71.23");
int count=countAttempts();var replay=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));assertThat(replay).isEqualTo(result);assertThat(countAttempts()).isEqualTo(count);
tx.executeWithoutResult(s->finance.handle(failure));assertThat(fees.selectById(replacement.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING");
assertThatThrownBy(()->replace(original,"12","duplicate-old")).hasMessageContaining("后续费用版本");
assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",original.getId()))).hasMessageContaining("有效费用");verifyNoInteractions(converter);
}
@Test void replacementWaitsForReleaseOfTheOriginalInternationalReservation() {
tx.executeWithoutResult(s->finance.releaseEstimate("9","PO-1",estimateId));
var estimate=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","estimate","POSITIVE",new BigDecimal("100"),"CNY",LocalDate.of(2026,9,1),null,"estimate")));
complete(estimate,"FREEZE");tx.execute(s->charges.confirm("9","PO-1","7",request("180","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"SETTLE_ACTUAL")));
tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-held","end failed settlement")));
assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("RELEASE_PENDING");
assertThatThrownBy(()->replace(original,"120","before-release")).hasMessageContaining("释放");
var releasing=fees.selectById(original.getId());tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(releasing.getWalletCommandEventId(),"RELEASE",9,"PROCUREMENT_FEE",releasing.getWalletBusinessNo(),"SUCCEEDED","RELEASED",new BigDecimal("100"),null,null,Instant.now(),"original-hold","release-ledger")));
var replacement=byNo(replace(original,"120","after-release").feeNos().get(0));
assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getWalletBusinessNo()).isEqualTo(replacement.getFeeNo());
assertThat(replacement.getParentFeeId()).isEqualTo(estimate.getId());assertThat(replacement.getAmount()).isEqualByComparingTo("120");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_CHARGES");
}
@Test void zeroReplacementDoesNotCreateMoneyAndReplacementRollbackLeavesNoVersionOrAudit() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate","end failed")));
doThrow(new IllegalStateException("outbox unavailable")).when(commands).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
assertThatThrownBy(()->replace(original,"90","rollback")).hasMessageContaining("outbox unavailable");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();assertThat(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT")).isNull();
int attempts=countAttempts();var result=replace(original,"0","waive");var replacement=byNo(result.feeNos().get(0));
assertThat(replacement.getWalletStatus()).isEqualTo("ZERO");assertThat(countAttempts()).isEqualTo(attempts);assertThat(finance.sourceLinked(replacement)).isTrue();
}
@Test void failedSampleReplacementStillRespectsCustomerBudgetAndPreservesPurchaseFacts() {
jdbc.update("DELETE FROM ll_overseas_procurement_fee WHERE purpose='SAMPLE_PAYMENT'");
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchase_confirmed_at=?,purchase_amount=10,purchase_currency='USD' WHERE order_id=11",LocalDateTime.of(2026,9,1,10,0),LocalDateTime.of(2026,9,1,10,0));
var original=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","sample","POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"actual sample")));
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-sample","correct price")));
assertThatThrownBy(()->replace(original,"20","over-budget")).hasMessageContaining("超过客户已授权预算");
tx.execute(s->lifecycle.authorizeBudget("9","PO-1","7",new com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget(version(),"new-budget",new BigDecimal("200"),"CNY",1001,"customer authorization")));
var replacement=byNo(replace(original,"20","within-budget").feeNos().get(0));assertThat(replacement.getPurpose()).isEqualTo("SAMPLE_PAYMENT");assertThat(replacement.getAmount()).isEqualByComparingTo("142.47");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("10");
assertThat(jdbc.queryForObject("SELECT purchase_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
}
@Test void concurrentReplacementsCannotBothChargeForTheSameTerminatedVersion()throws Exception {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);
tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"stop","end original")));
var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version();
try {
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(String key:List.of("replace-a","replace-b"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version,key,new BigDecimal("90"),1001,"verified replacement")));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}}));
gate.countDown();int succeeded=0;for(var future:futures)if(future.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++;
assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1);
assertThat(finance.all(11).stream().filter(f->"INTERNATIONAL_FREIGHT".equals(f.getPurpose()) && Boolean.TRUE.equals(f.getActive())).count()).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();}
}
@Test void financialTerminationAfterHandoffPreservesOriginalChargeConfirmationAndFulfillment() {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var originalConfirmed=jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class);
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
var adjustment=byNo(correct(root,"120","after-handoff").feeNos().get(0));tx.executeWithoutResult(s->finance.handle(failed(adjustment,"FREEZE")));
tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate-after-handoff","end failed correction")));
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isEqualTo(originalConfirmed);
}
private WalletCommandCompletedV1 failed(OverseasProcurementFee fee,String operation){return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"FAILED",null,fee.getAmount(),"FAILED","definitive failure",Instant.now(),null,null);}
private ProcurementAdjustmentService.Result replace(OverseasProcurementFee original,String target,String key){return tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version(),key,new BigDecimal(target),1001,"replacement verified")));}
private ProcurementAdjustmentService.Result correct(OverseasProcurementFee root,String target,String key){return tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),key,new BigDecimal(target),1001,"verified target")));}
@Test void concurrentTargetsCannotBothSupplementTheSameOldNetAmount()throws Exception {
tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);
var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version();
try {
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(String target:List.of("120","130"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.correct("9","PO-1",target,root.getId(),new Correct(version,"parallel-"+target,new BigDecimal(target),1001,"correct target")));return true;}
catch(org.springframework.web.server.ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}}));
gate.countDown();int succeeded=0;for(var result:futures)if(result.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++;
assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1);
assertThat(finance.all(11).stream().filter(f->"ADJUSTMENT".equals(f.getPurpose())).count()).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();}
}
private Selection selection(String amount){return new Selection(null,new Amount(new BigDecimal(amount).signum()>0?"POSITIVE":"ZERO",new BigDecimal(amount),"CNY",LocalDate.of(2026,9,1),new BigDecimal(amount).signum()>0?1001L:null,"actual cost"));}
private Confirm request(String international,String domestic){return new Confirm(version(),"charges-"+version(),selection(international),selection("0"),selection(domestic),"NOT_APPLICABLE",List.of(),"all determined");}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private int countAttempts(){return jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class);}
private OverseasProcurementFee current(ProcurementFeePurpose purpose){return finance.current(11,purpose,purpose.name());}
private OverseasProcurementFee byNo(String no){return finance.all(11).stream().filter(f->no.equals(f.getFeeNo())).findFirst().orElseThrow();}
private void settle(OverseasProcurementFee fee){complete(fee,"FREEZE");complete(fees.selectById(fee.getId()),"SETTLE");var saved=fees.selectById(fee.getId());jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE',?,'PUBLISHED')",String.valueOf(saved.getSourceFeeId()));}
private void complete(OverseasProcurementFee fee,String operation){String status="REFUND".equals(operation)?"REFUNDED":"FREEZE".equals(operation)?"FROZEN":"SETTLED";tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"SUCCEEDED",status,fee.getAmount(),null,null,Instant.now(),"REFUND".equals(operation)?null:"hold-"+fee.getId(),"ledger-"+fee.getId()+"-"+operation)));}
}
@@ -1,130 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ProcurementControlPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;ProcurementFinanceService finance;
ProcurementControlService service;ProcurementLifecycleService lifecycle;ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds){
@Override public int update(String sql,Object... args){return super.update(sql.replaceAll("(?i)CAST\\(\\? AS JSON\\)","? FORMAT JSON"),args);}
};tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_flow","ll_procurement_command","ll_procurement_receipt","ll_procurement_disposition",
"ll_procurement_carton","ll_procurement_shipment","ll_procurement_shipment_item","ll_procurement_control_note","ll_procurement_closure","ll_procurement_closure_item")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_amount,purchase_currency) VALUES(11,'9',?,10,100,'CNY')",LocalDateTime.now().minusDays(1));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','EXCEPTION',1001,'proof.pdf','application/pdf',10,'7')");
finance=mock(ProcurementFinanceService.class);warehouses=mock(ProcurementWarehouseDirectory.class);
when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE"));
var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() {
assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10));
assertThatThrownBy(()->close(List.of())).hasMessageContaining("货物去向未明确");
var request=request(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")));
var result=tx.execute(s->service.close("9","PO-1","7",request));assertThat(result.status()).isEqualTo("EXCEPTION_CLOSED");assertThat(result.remaining()).isEmpty();
tx.execute(s->service.close("9","PO-1","7",request));assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();verify(finance,times(2)).requireShipmentReady(11);verifyNoMoreInteractions(finance);
}
@Test void partialReceiptsRequireExactGoodDamagedExtraAndUnreceivedSources() {
receipt(101,"OVERSEAS",null,6,2,1);
assertThat(service.read("9","PO-1").remaining()).hasSize(4);
assertThatThrownBy(()->close(List.of(resolution("GOOD",101L,5,"RETURNED_TO_SUPPLIER")))).hasMessageContaining("恰好覆盖");
var all=List.of(resolution("GOOD",101L,6,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",101L,2,"DISPOSED"),resolution("EXTRA",101L,1,"RETURNED_TO_SUPPLIER"),resolution("PURCHASE_REMAINDER",null,2,"SUPPLIER_RETAINED"));
assertThat(close(all).status()).isEqualTo("EXCEPTION_CLOSED");
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_closure_item",Long.class)).isEqualTo(11L);
assertThat(jdbc.queryForObject("SELECT good_quantity FROM ll_procurement_receipt",Long.class)).isEqualTo(6L);
}
@Test void internationalShipmentIsNotRecountedAndDomesticRemainingKeepsOriginalCartonSources() {
receipt(101,"OVERSEAS",null,10,0,0);international();receipt(102,"DOMESTIC",201L,3,1,1);
var remaining=service.read("9","PO-1").remaining();assertThat(remaining).hasSize(5);assertThat(remaining).noneMatch(r->r.sourceId()!=null && r.sourceId()==101);
assertThat(remaining).contains(new ProcurementControlService.Remaining("TRANSIT_REMAINDER",201L,"C1",2),new ProcurementControlService.Remaining("TRANSIT_REMAINDER",202L,"C2",4));
close(List.of(resolution("GOOD",102L,3,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",102L,1,"DISPOSED"),resolution("EXTRA",102L,1,"RETURNED_TO_SUPPLIER"),resolution("TRANSIT_REMAINDER",201L,2,"LOSS_CONFIRMED"),resolution("TRANSIT_REMAINDER",202L,4,"RETURNED_TO_SENDER")));
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
}
@Test void unresolvedMoneyCannotBeErasedByClosing() {
doThrow(ProcurementLifecycleService.conflict("资金未决")).when(finance).requireShipmentReady(11);
assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("资金未决");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_OVERSEAS_RECEIPT");
}
@Test void evidenceBackedBlockerMustBeResolvedBeforePlannedOperationsAndClosure() {
tx.execute(s->service.note("9","PO-1","7",new Note(version(),"note","PURCHASE",null,true,1001,"internal evidence","Waiting for agreed handling")));
assertThatThrownBy(()->lifecycle.requireNoBlockers(orders.findNo("9","PO-1"))).hasMessageContaining("阻断异常");
assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("阻断异常");
long id=jdbc.queryForObject("SELECT id FROM ll_procurement_control_note",Long.class);
tx.execute(s->service.resolve("9","PO-1","7",id,new Resolve(version(),"resolve",1001,"customer confirms supplier retention")));
lifecycle.requireNoBlockers(orders.findNo("9","PO-1"));assertThat(close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))).status()).isEqualTo("EXCEPTION_CLOSED");
assertThat(jdbc.queryForObject("SELECT reason FROM ll_procurement_control_note",String.class)).isEqualTo("internal evidence");
}
@Test void warehouseCorrectionBeforePackingChangesEffectiveWarehouseAndPreservesOriginalReceipt() {
receipt(101,"OVERSEAS",null,10,0,0);warehouse();
tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"typed wrong warehouse")));
assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(92L);
assertThat(jdbc.queryForObject("SELECT warehouse_id FROM ll_procurement_receipt",Long.class)).isEqualTo(91L);
assertThat(jdbc.queryForObject("SELECT original_value_json FROM ll_procurement_control_note",String.class)).contains("91");
var snapshot=service.read("9","PO-1");
assertThat(snapshot.notes().get(0).get("original_value_json")).isInstanceOf(com.fasterxml.jackson.databind.JsonNode.class);
assertThat(((com.fasterxml.jackson.databind.JsonNode)snapshot.notes().get(0).get("original_value_json")).get("warehouseId").asLong()).isEqualTo(91);
assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isFalse();
}
@Test void warehouseAfterDownstreamAndPostShipmentCorrectionsOnlyAppendFacts() {
receipt(101,"OVERSEAS",null,10,0,0);warehouse();international();
tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"observed later")));
assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(91L);
assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isTrue();
jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11");
tx.execute(s->service.note("9","PO-1","7",new Note(version(),"after","SHIPMENT",301L,false,1001,"post handoff correction","Shipment correction recorded")));
assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
}
@Test void wrongOrderProofOrFactAndDuplicateRemainingSourceAreRejected() {
assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户");
assertThatThrownBy(()->tx.execute(s->service.note("9","PO-1","7",new Note(version(),"other","SHIPMENT",999L,false,1001,"other fact",null)))).hasMessageContaining("不属于");
var line=resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED");assertThatThrownBy(()->close(List.of(line,line))).hasMessageContaining("恰好覆盖");
assertThatThrownBy(()->tx.execute(s->service.close("9","PO-1","7",new Close(version(),"other-proof",List.of(line),999,"no further charges","closed","Agreement")))).hasMessageContaining("凭证不属于");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();
}
private void warehouse(){jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91,actual_warehouse_code='OLD',actual_warehouse_name='Original warehouse' WHERE order_id=11");}
private void international(){jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,401,'C1',20,30,40,6,6),(202,11,401,'C2',20,30,40,4,4)");jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(301,11,'9','PS-1','OVERSEAS',401,?,'{}','7')",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(301,201,6,'carrier','T1','{}'),(301,202,4,'carrier','T2','{}')");}
private void receipt(long id,String leg,Long carton,long good,long damaged,long extra){jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(?,11,'9',?,?,?,91,?,?,?,?, '7')",id,"PR-"+id,leg,carton,good,damaged,extra,LocalDateTime.now().minusHours(1));}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private Resolution resolution(String kind,Long source,long quantity,String decision){return new Resolution(kind,source,quantity,decision,"verified actual destination",1001);}
private Close request(List<Resolution> lines){return new Close(version(),"close-"+version(),lines,1001,"known fees resolved; no further customer charges","agreed stop","Procurement closed by agreement");}
private ProcurementControlService.Snapshot close(List<Resolution> lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));}
}
@@ -1,31 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper;
import com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ProcurementDirectionPersistenceTest {
@Test void directionGateReadsOnlyThisOrdersRecordedFacts() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_procurement_flow(order_id BIGINT PRIMARY KEY,purchased_at TIMESTAMP,actual_warehouse_id BIGINT,overseas_shipped_at TIMESTAMP)");
var config = new MybatisConfiguration(); config.addMapper(ProcurementDirectionMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var repository = new ProcurementDirectionRepository(new SqlSessionTemplate(factory.getObject()).getMapper(ProcurementDirectionMapper.class));
assertThat(repository.hasStarted(1)).isFalse();
jdbc.update("INSERT INTO ll_procurement_flow(order_id) VALUES(1)");
assertThat(repository.hasStarted(1)).isFalse();
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=CURRENT_TIMESTAMP WHERE order_id=1");
assertThat(repository.hasStarted(1)).isTrue();
assertThat(repository.hasStarted(2)).isFalse();
jdbc.update("UPDATE ll_procurement_flow SET purchased_at=NULL,actual_warehouse_id=91 WHERE order_id=1");
assertThat(repository.hasStarted(1)).isTrue();
}
}
@@ -1,287 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real fee rows, attempt rows, locking and rollback; only external boundaries are mocked. */
class ProcurementFinancePersistenceTest {
JdbcTemplate jdbc;
TransactionTemplate tx;
OverseasProcurementFeeMapper fees;
OverseasProcurementOrder order;
OverseasFeeSourceService sources;
WalletCommandOutboxService commands;
ProcurementFinanceService service;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String name : new String[]{"ll_overseas_procurement_fee", "ll_procurement_wallet_attempt"}) {
var match = Pattern.compile("CREATE TABLE IF NOT EXISTS " + name + " \\(.*?\\) ENGINE=InnoDB[^;]*;", Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as("source schema contains %s",name).isTrue();
jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;", ");"));
}
jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))");
var config=new MybatisConfiguration(); config.addMapper(OverseasProcurementFeeMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
fees=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementFeeMapper.class);
var orders=mock(OverseasProcurementOrderMapper.class); var files=mock(OverseasProcurementFileMapper.class);
order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");
order.setOrderNo("PO-1");order.setPurchaseQuantity(10L);order.setInboundQuantity(10L);order.setStatus("WAIT_CHARGES");
when(orders.lockNo("9","PO-1")).thenReturn(order);when(orders.selectById(11L)).thenReturn(order);
var voucher=new OverseasProcurementFile();voucher.setFileId(1L);voucher.setFileName("voucher.pdf");voucher.setCategory("VOUCHER");
when(files.selectOne(any())).thenReturn(voucher);
var converter=mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i -> new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1)));
sources=mock(OverseasFeeSourceService.class);
var source=new OverseasFeeSource();source.setSourceFeeId(901L);
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(source);
commands=mock(WalletCommandOutboxService.class);AtomicInteger sequence=new AtomicInteger();
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(service, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
}
@Test void actualFeePublishesOnlyAfterSuccessfulConsumption() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100");
var frozen=result(fee,"FREEZE","SUCCEEDED","FROZEN","100");handle(frozen);
verifyNoInteractions(sources);
var pending=fees.selectById(fee.getId());assertThat(pending.getWalletStatus()).isEqualTo("SETTLE_PENDING");
var settled=result(pending,"SETTLE","SUCCEEDED","SETTLED","100");handle(settled);handle(settled);
var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED");
assertThat(saved.getSourceFeeId()).isEqualTo(901L);
verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId())));
assertThat(service.sourceLinked(saved)).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
}
@Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN");
verify(commands,never()).publish(eq("SETTLE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
var request=request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"POSITIVE","60");
var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request));
assertThat(actual.getWalletBusinessNo()).isEqualTo(estimate.getFeeNo());
handle(result(actual,"SETTLE_ACTUAL","SUCCEEDED","SETTLED","60"));
assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100");
assertThat(fees.selectById(actual.getId()).getAmount()).isEqualByComparingTo("60");
assertThat(tx.execute(s -> service.confirmInternational("9","PO-1","7",request)).getId()).isEqualTo(actual.getId());
verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.businessNo().equals(estimate.getFeeNo()) && r.amount().compareTo(new BigDecimal("60"))==0),any(),any());
}
@Test void zeroActualReleasesEstimateWithoutCreatingAnActualReceivable() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"ZERO","0")));
handle(result(actual,"RELEASE","SUCCEEDED","RELEASED","100"));
assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("RELEASED");
assertThat(service.sourceLinked(fees.selectById(actual.getId()))).isTrue();
verifyNoInteractions(sources);
}
@Test void unknownRequestCannotRetryAndOldFailedResultCannotOverwriteNewAttempt() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10");
assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",fee.getId()))).hasMessageContaining("未明确失败");
var failed=result(fee,"FREEZE","FAILED",null,"10");handle(failed);
var retry=tx.execute(s -> service.retry("9","PO-1",fee.getId()));
assertThat(retry.getWalletCommandEventId()).isNotEqualTo(failed.commandEventId());
handle(failed);
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2);
}
@Test void pendingOriginalRejectsReplacementAndLateSuccessKeepsOriginalAmountAndLedger() {
var original=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10");
var originalFreeze=result(original,"FREEZE","SUCCEEDED","FROZEN","10");
assertPendingVersionCannotBeReplaced(original);
// Replaying a timed-out confirmation returns the original command, never a new attempt.
assertThat(confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10").getWalletCommandEventId())
.isEqualTo(originalFreeze.commandEventId());
handle(originalFreeze);
var settling=fees.selectById(original.getId());
assertPendingVersionCannotBeReplaced(settling);
assertThat(confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10").getWalletCommandEventId())
.isEqualTo(settling.getWalletCommandEventId());
var lateSuccess=result(settling,"SETTLE","SUCCEEDED","SETTLED","10");
handle(lateSuccess);
// A delayed freeze receipt after settlement must not regress the settled projection.
handle(originalFreeze);
handle(lateSuccess);
var saved=fees.selectById(original.getId());
assertThat(saved.getFeeVersion()).isEqualTo(1);
assertThat(saved.getOriginalAmount()).isEqualByComparingTo("10");
assertThat(saved.getAmount()).isEqualByComparingTo("10");
assertThat(saved.getWalletStatus()).isEqualTo("SETTLED");
assertThat(saved.getSettledCommandEventId()).isEqualTo(lateSuccess.commandEventId());
assertThat(saved.getWalletLedgerId()).isEqualTo(lateSuccess.walletLedgerId());
assertThat(saved.getSourceFeeId()).isEqualTo(901L);
assertThat(service.all(11)).hasSize(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=? AND status='SUCCEEDED'",Integer.class,original.getId())).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT fee_id FROM ll_procurement_wallet_attempt WHERE command_event_id=?",Long.class,lateSuccess.commandEventId())).isEqualTo(original.getId());
verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
verify(commands,never()).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong());
verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.amount().compareTo(new BigDecimal("10"))==0),any(),
argThat(ref -> ref.commandEventId().equals(lateSuccess.commandEventId())));
}
private void assertPendingVersionCannotBeReplaced(OverseasProcurementFee original) {
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.DUTY,"POSITIVE","20")).hasMessageContaining("已确认");
assertThatThrownBy(() -> tx.execute(s -> service.replaceFailed("9","PO-1","7",original.getId(),new BigDecimal("20"),1,"corrected target")))
.hasMessageContaining("须先终止");
assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",original.getId()))).hasMessageContaining("未明确失败");
assertThatThrownBy(() -> tx.executeWithoutResult(s -> service.terminateFailed("9","PO-1",original.getId()))).hasMessageContaining("明确失败");
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",original.getId(),BigDecimal.ONE,1,"corrected target","pending-refund")))
.hasMessageContaining("已成功实扣");
assertThat(fees.selectById(original.getId()).getWalletCommandEventId()).isEqualTo(original.getWalletCommandEventId());
}
@Test void pendingEstimateCannotBeReleasedOrReplacedBeforeLateFreezeResult() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","10");
assertThatThrownBy(() -> tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId())))
.hasMessageContaining("未决");
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","20")).hasMessageContaining("已确认");
assertThat(confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","10").getId()).isEqualTo(estimate.getId());
var lateFreeze=result(estimate,"FREEZE","SUCCEEDED","FROZEN","10");
handle(lateFreeze);handle(lateFreeze);
assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN");
assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("10");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class)).isEqualTo(1);
verify(commands).publish(eq("FREEZE"),anyLong(),anyString(),eq(estimate.getFeeNo()),eq(new BigDecimal("10")),anyString(),anyLong());
verifyNoMoreInteractions(commands);
verifyNoInteractions(sources);
}
@Test void sourceFailureRollsBackResultProjectionAndRetriesWithoutAnotherDebit() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","10"));
var pending=fees.selectById(fee.getId());var result=result(pending,"SETTLE","SUCCEEDED","SETTLED","10");
when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenThrow(new IllegalStateException("source unavailable"));
assertThatThrownBy(() -> handle(result)).hasMessage("source unavailable");
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLE_PENDING");
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("PENDING");
verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
var recoveredSource=new OverseasFeeSource();recoveredSource.setSourceFeeId(901L);
doReturn(recoveredSource).when(sources).publishSettled(anyString(),anyString(),any(),any(),any());
handle(result);handle(result);
var recovered=fees.selectById(fee.getId());
assertThat(recovered.getWalletStatus()).isEqualTo("SETTLED");
assertThat(recovered.getSourceFeeId()).isEqualTo(901L);
assertThat(recovered.getSettledCommandEventId()).isEqualTo(result.commandEventId());
assertThat(recovered.getWalletLedgerId()).isEqualTo(result.walletLedgerId());
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("SUCCEEDED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2);
verify(sources,times(2)).publishSettled(anyString(),anyString(),any(),any(),argThat(ref->ref.commandEventId().equals(result.commandEventId())));
verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());
assertThat(service.sourceLinked(recovered)).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
service.requireShipmentReady(11);
}
@Test void freeSampleDoesNotPublishZeroMoneyAndCannotBeChangedByReplay() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"ZERO","0");
assertThat(fee.getWalletStatus()).isEqualTo("ZERO");assertThat(service.sourceLinked(fee)).isTrue();
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","1")).hasMessageContaining("已确认");
verifyNoInteractions(commands,sources);
}
@Test void repriceWaitsForReleaseAndCreatesANewImmutableVersion() {
var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100"));
tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId()));
assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80")).hasMessageContaining("已确认");
handle(result(fees.selectById(estimate.getId()),"RELEASE","SUCCEEDED","RELEASED","100"));
var revised=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80");
assertThat(revised.getFeeVersion()).isEqualTo(2);
assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100");
assertThat(fees.selectById(estimate.getId()).getActive()).isFalse();
}
@Test void refundUsesOriginalConsumptionAndIsIdempotentByBusinessRequest() {
var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","100");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(refund.getFeeNo()).matches("PF[0-9]{12}");
var replay = tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(replay.getId()).isEqualTo(refund.getId());
assertThat(replay.getFeeNo()).isEqualTo(refund.getFeeNo());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("21"),1,"correction","refund-key")))
.hasMessageContaining("退款幂等键对应不同");
verify(commands, times(1)).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key")))
.hasMessageContaining("未完成的调整或退款");
handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(),
"SUCCEEDED","REFUNDED",new BigDecimal("20"),null,null,Instant.parse("2026-10-09T10:00:00Z"),null,"33"));
assertThat(fees.selectById(refund.getId()).getWalletStatus()).isEqualTo("REFUNDED");
assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLED");
verify(commands).publishRefund(eq(9L),eq("PROCUREMENT_FEE"),eq(refund.getFeeNo()),any(),eq("32"),eq("correction"),anyLong());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("81"),1,"correction","too-much")))
.hasMessageContaining("可退余额");
}
@Test void finalHandoffWaitsForConsumptionSourceAndAnyPendingRefund() {
var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100");
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero();
service.requireShipmentReady(11);
tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金");
assertThatThrownBy(()->service.requireAdjustmentsComplete(11)).hasMessageContaining("财务调整或退款");
}
@Test void unusedFrozenEstimateMustBeReleasedBeforeFinalHandoff() {
var fee=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100");
handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100"));
assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("尚未结清或释放");
tx.executeWithoutResult(s->service.releaseEstimate("9","PO-1",fee.getId()));
handle(result(fees.selectById(fee.getId()),"RELEASE","SUCCEEDED","RELEASED","100"));
service.requireShipmentReady(11);
}
private OverseasProcurementFee confirm(ProcurementFeePurpose purpose,String determination,String amount) {
return tx.execute(s -> service.confirm("9","PO-1","7",request(purpose,determination,amount)));
}
private ProcurementFinanceService.Confirmation request(ProcurementFeePurpose purpose,String determination,String amount) {
return new ProcurementFinanceService.Confirmation(purpose,purpose.name(),purpose.label(),determination,new BigDecimal(amount),"CNY",LocalDate.of(2026,10,9),1L,"confirmed basis");
}
private WalletCommandCompletedV1 result(OverseasProcurementFee fee,String operation,String outcome,String state,String amount) {
return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),outcome,state,new BigDecimal(amount),
"FAILED".equals(outcome)?"FIN-WALLET-INSUFFICIENT":null,null,Instant.parse("2026-10-09T10:00:00Z"),"SUCCEEDED".equals(outcome)?"31":null,"SUCCEEDED".equals(outcome)?"32":null);
}
private void handle(WalletCommandCompletedV1 result) { tx.executeWithoutResult(s -> service.handle(result)); }
}
@@ -1,238 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementPhysicalPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementPhysicalService service;ProcurementFinanceService finance;OverseasProcurementFee payment;
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_procurement_budget_authorization","ll_procurement_receipt","ll_procurement_disposition","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");
order.setContactName("buyer");order.setContactPhone("13800000000");order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setReviewedAt(LocalDateTime.now().minusDays(3));
order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at) VALUES(11,'9',?,10,?)",LocalDateTime.now().minusDays(2),LocalDateTime.now().minusDays(2));
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')");
var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE"));
finance=mock(ProcurementFinanceService.class);payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED");
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules());
service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() {
var first=receive(Leg.OVERSEAS,null,6,0,0,"r1");long source1=first.receipts().get(0).id();
var second=receive(Leg.OVERSEAS,null,4,0,0,"r2");long source2=second.receipts().get(1).id();
finish(Leg.OVERSEAS);
var draft=pack(List.of(carton("C1",new Source(source1,5)),carton("C2",new Source(source1,1),new Source(source2,4))));
var confirmed=tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")));
assertThat(confirmed.status()).isEqualTo("WAIT_LABELS");assertThat(confirmed.packingStatus()).isEqualTo("CONFIRMED");
assertThat(confirmed.cartons()).extracting(ProcurementPhysicalService.CartonRow::quantity).containsExactly(5L,5L);
assertThat(confirmed.cartons().get(1).sources()).hasSize(2);
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_name='ll_inventory_balance'",Integer.class)).isZero();
}
@Test void unresolvedDamageExtraAndShortageBlockCompletionUntilEvidenceExists() {
var receipt=receive(Leg.OVERSEAS,null,6,2,1,"r1").receipts().get(0);
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损或多到");
dispose(receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED");dispose(receipt.id(),"EXTRA",1,"RETURN_EXTRA");
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("未收齐");
dispose(null,"SHORTAGE",2,"ACCEPT_SHORTAGE");
assertThat(finish(Leg.OVERSEAS).status()).isEqualTo("WAIT_PACKING");
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(6L);
verify(finance,never()).refund(anyString(),anyString(),anyString(),anyLong(),any(),anyLong(),anyString(),anyString());
}
@Test void overReceiptAndOtherOrderEvidenceAreRejectedWithoutCounting() {
var receipt=receive(Leg.OVERSEAS,null,8,2,0,"r1").receipts().get(0);
assertThatThrownBy(()->receive(Leg.OVERSEAS,null,1,0,0,"r2")).hasMessageContaining("超过来源");
assertThatThrownBy(()->tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"dispose",Leg.OVERSEAS,null,receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED",999,"wrong file"))))
.hasMessageContaining("凭证不属于");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_receipt",Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_disposition",Integer.class)).isZero();
}
@Test void draftsAreAllowedBeforePaymentButConfirmationWaitsForMoneyAndAllSources() {
when(finance.sourceLinked(payment)).thenReturn(false);
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var draft=pack(List.of(carton("C1",new Source(receipt.id(),10))));
assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))))
.hasMessageContaining("样品货款");
when(finance.sourceLinked(payment)).thenReturn(true);
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")));
assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("CONFIRMED");
}
@Test void quantityDoubleAllocationAndExcessPrecisionNeverPersist() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),6)),carton("C2",new Source(receipt.id(),6))))).hasMessageContaining("重复分配");
var bad=new Carton("C1",new BigDecimal("1.001"),BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE,List.of(new Source(receipt.id(),10)));
assertThatThrownBy(()->pack(List.of(bad))).hasMessageContaining("最多两位");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero();
}
@Test void nonPositiveMeasurementsAndOverPreciseWeightCannotPersistPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);
var source=List.of(new Source(receipt.id(),10));
for(BigDecimal invalid:List.of(BigDecimal.ZERO,BigDecimal.ONE.negate())) {
for(int axis=0;axis<4;axis++) {
BigDecimal[] values={BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE};values[axis]=invalid;
var carton=new Carton("C1",values[0],values[1],values[2],values[3],source);
assertThatThrownBy(()->pack(List.of(carton))).hasMessageContaining("正数");
}
}
assertThatThrownBy(()->pack(List.of(new Carton("C1",BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,new BigDecimal("1.0001"),source))))
.hasMessageContaining("最多三位");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero();
}
@Test void foreignReceiptAndIncompleteAllocationCannotBecomeConfirmedPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(999,10))))).hasMessageContaining("不属于本单");
var draft=pack(List.of(carton("C1",new Source(receipt.id(),9))));
assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"incomplete",draft.packingVersion(),"checked"))))
.hasMessageContaining("全部");
assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("DRAFT");
}
@Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var first=pack(List.of(carton("C1",new Source(receipt.id(),10))));
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",first.packingVersion(),"checked")));
var second=pack(List.of(carton("C1",new Source(receipt.id(),5)),carton("C2",new Source(receipt.id(),5))));
assertThat(second.packingVersion()).isEqualTo(2);assertThat(second.packingStatus()).isEqualTo("DRAFT");
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest WHERE version=1",String.class)).isEqualTo("SUPERSEDED");
assertThat(jdbc.queryForObject("SELECT packing_version FROM ll_procurement_flow WHERE order_id=11",Integer.class)).isNull();
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now());
assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),10))))).hasMessageContaining("已实际交寄");
}
@Test void domesticReceiptsMustMatchEachOriginalShippedCarton() {
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,6,'carrier','T1','{}'),(201,102,4,'carrier','T2','{}')");
assertThatThrownBy(()->receive(Leg.DOMESTIC,999L,1,0,0,"wrong")).hasMessageContaining("不属于本单");
receive(Leg.DOMESTIC,101L,4,0,0,"d1");assertThatThrownBy(()->finish(Leg.DOMESTIC)).hasMessageContaining("未收齐");
receive(Leg.DOMESTIC,101L,2,0,0,"d2");receive(Leg.DOMESTIC,102L,4,0,0,"d3");
assertThat(finish(Leg.DOMESTIC).status()).isEqualTo("WAIT_CHARGES");
assertThat(service.read("9","PO-1").receipts()).hasSize(3);
}
@Test void concurrentReceiversCannotCountTheSameWholePurchaseTwice()throws Exception {
ExecutorService executor=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1);
try {
var one=executor.submit(()->attemptReceipt(start,"one"));var two=executor.submit(()->attemptReceipt(start,"two"));start.countDown();
assertThat((one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt",Long.class)).isEqualTo(10L);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();}
}
@Test void overseasCorrectionRequiresWithdrawalAndKeepsOriginalReceiptAndPacking() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS);
var packing=pack(List.of(carton("C1",new Source(receipt.id(),10))));
tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",packing.packingVersion(),"checked")));
assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,9,1)).hasMessageContaining("须先撤回");
tx.execute(s->service.withdrawPacking("9","PO-1","7",new Command(version(),"withdraw","record correction")));
var corrected=correct(receipt.id(),Leg.OVERSEAS,null,9,1);
assertThat(corrected.receipts()).hasSize(2);assertThat(corrected.receipts().get(0).active()).isFalse();
assertThat(corrected.receipts().get(0).goodQuantity()).isEqualTo(10);
assertThat(corrected.receipts().get(1).goodQuantity()).isEqualTo(9);
assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_carton",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest",String.class)).isEqualTo("SUPERSEDED");
assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损");
}
@Test void domesticCorrectionDoesNotUndoInternationalShipmentAndReopensCharges() {
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')");
var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC);
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
correct(receipt.id(),Leg.DOMESTIC,101L,8,2);
assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt WHERE active=1",Long.class)).isEqualTo(8L);
verifyNoInteractions(finance);
}
@Test void overseasShipmentRejectsReceiptCorrectionWithoutChangingOriginalFacts() {
var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=CURRENT_TIMESTAMP WHERE order_id=11");
long before=version();
assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,8,2)).hasMessageContaining("本段已实际交寄");
assertThat(service.read("9","PO-1").receipts()).containsExactly(receipt);
assertThat(version()).isEqualTo(before);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command WHERE action='CORRECT_RECEIPT'",Integer.class)).isZero();
}
@Test void customerShipmentRejectsDomesticCorrectionWithoutReopeningReceiptOrCharges() {
jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2));
jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')");
var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC);
jdbc.update("UPDATE ll_procurement_flow SET customer_shipped_at=CURRENT_TIMESTAMP,charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
long before=version();
assertThatThrownBy(()->correct(receipt.id(),Leg.DOMESTIC,101L,8,2)).hasMessageContaining("本段已实际交寄");
assertThat(service.read("9","PO-1").receipts()).containsExactly(receipt);
assertThat(version()).isEqualTo(before);
assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull();
assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
}
private ProcurementPhysicalService.Snapshot correct(long id,Leg leg,Long source,long good,long damaged) {
return tx.execute(s->service.correctReceipt("9","PO-1","7",id,new Correction(new Receipt(version(),"correction",leg,
leg==Leg.OVERSEAS?91L:null,source,good,damaged,0,LocalDateTime.now().minusHours(1),"evidence-backed correction"),1001)));
}
private boolean attemptReceipt(CountDownLatch start,String key)throws Exception {
if(!start.await(5,TimeUnit.SECONDS))throw new AssertionError("timeout");
try{tx.execute(s->service.receive("9","PO-1","7",new Receipt(0L,key,Leg.OVERSEAS,91L,null,10,0,0,LocalDateTime.now().minusHours(1),"actual")));return true;}
catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}
}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private ProcurementPhysicalService.Snapshot receive(Leg leg,Long source,long good,long damaged,long extra,String key){return tx.execute(s->service.receive("9","PO-1","7",new Receipt(version(),key,leg,leg==Leg.OVERSEAS?91L:null,source,good,damaged,extra,LocalDateTime.now().minusHours(1),"actual receipt")));}
private ProcurementPhysicalService.Snapshot finish(Leg leg){return tx.execute(s->service.finishReceipt("9","PO-1","7",new FinishReceipt(version(),"finish-"+leg+"-"+version(),leg,"verified")));}
private ProcurementPhysicalService.Snapshot pack(List<Carton> cartons){return tx.execute(s->service.savePacking("9","PO-1","7",new Packing(version(),"packing-"+version(),cartons,"actual packing")));}
private Carton carton(String no,Source... sources){return new Carton(no,new BigDecimal("20.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),List.of(sources));}
private void dispose(Long receipt,String kind,long quantity,String decision){tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"disposition-"+kind,Leg.OVERSEAS,null,receipt,kind,quantity,decision,1001,"customer and physical proof")));}
}
@@ -1,184 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ProcurementPurchasePersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementLifecycleService service;ProcurementFinanceService finance;
OverseasFeeCurrencyConverter converter;ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:new String[]{"ll_overseas_procurement_order","ll_procurement_flow","ll_procurement_command","ll_procurement_budget_authorization"}) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
orders=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementOrderMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");
order.setPurchaseCategory("sample");order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);
order.setBudgetUnitPrice(new BigDecimal("10"));order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000");
order.setStatus("WAIT_PURCHASE");order.setReviewedAt(LocalDateTime.now().minusDays(3));order.setVersion(0L);
order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order);
var files=mock(OverseasProcurementFileMapper.class);var file=new OverseasProcurementFile();file.setCategory("VOUCHER");
when(files.selectOne(any())).thenReturn(file);
finance=mock(ProcurementFinanceService.class);var payment=new AtomicReference<OverseasProcurementFee>();
when(finance.current(eq(11L),eq(ProcurementFeePurpose.SAMPLE_PAYMENT),eq("SAMPLE_PAYMENT"))).thenAnswer(i->payment.get());
when(finance.confirm(anyString(),anyString(),anyString(),any())).thenAnswer(i->{
var fee=new OverseasProcurementFee();fee.setFeeNo("PF-1");fee.setWalletStatus("FREEZE_PENDING");payment.set(fee);return fee;
});
converter=mock(OverseasFeeCurrencyConverter.class);
when(converter.convert(anyString(),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion(i.getArgument(0),i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1)));
warehouses=mock(ProcurementWarehouseDirectory.class);
service=new ProcurementLifecycleService(orders,files,warehouses,finance,converter,jdbc,new ObjectMapper().findAndRegisterModules());
}
@Test void purchaseRecordsFactAndChargesOnceWithoutWaitingForInbound() {
Purchase request=purchase("80","Sample");
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isTrue();
assertThat(result.status()).isEqualTo("WAIT_OVERSEAS_RECEIPT");assertThat(result.version()).isEqualTo(1);
assertThat(result.purchase().walletStatus()).isEqualTo("FREEZE_PENDING");
assertThat(service.flow(11).get("purchased_at")).isInstanceOf(LocalDateTime.class);
assertThat(service.flow(11).get("purchase_confirmed_at")).isInstanceOf(LocalDateTime.class);
tx.execute(s->service.recordPurchase("9","PO-1","7",request));
verify(finance).confirm(eq("9"),eq("PO-1"),eq("7"),argThat(r->r.originalAmount().compareTo(new BigDecimal("80"))==0));
assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1);
}
@Test void overBudgetFactSurvivesUntilAuthorizedAndCanThenConfirm() {
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("120","Sample")));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("超过");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("130"),"CNY",1,"customer approval")));
var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized")));
assertThat(confirmed.purchase().confirmed()).isTrue();assertThat(confirmed.purchase().issue()).isNull();
assertThat(jdbc.queryForObject("SELECT previous_amount FROM ll_procurement_budget_authorization",BigDecimal.class)).isEqualByComparingTo("100");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("120");
}
@Test void missingRateKeepsActualPurchaseAndDoesNotInventCharge() {
when(converter.convert(anyString(),any(),any())).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"rate missing"));
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("汇率");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void amountBeyondSourceCapacityKeepsPurchaseFactWithoutCharging() {
jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=9999999999999999 WHERE id=11");
var request=purchase("100000000000000.00","Sample");
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request));
assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse();
assertThat(result.purchase().issue()).contains("超出", "已保留真实购买事实");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("100000000000000.00");
tx.execute(s->service.recordPurchase("9","PO-1","7",request));
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1);
}
@Test void wrongGoodsCannotBeAuthorizedByIncreasingBudget() {
var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Wrong item")));
assertThat(result.purchase().issue()).contains("商品");
tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("200"),"CNY",1,"customer approval")));
var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized")));
assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void unreviewedPurchaseFactCannotConfirmOrCharge() {
jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("简化采购");
assertThat(service.flow(11)).isEmpty();
assertThat(orders.findNo("9","PO-1").getVersion()).isZero();
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void missingBudgetCannotConfirmOrInventACharge() {
jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=NULL WHERE id=11");
var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThat(recorded.purchase().recorded()).isTrue();
assertThat(recorded.purchase().confirmed()).isFalse();
assertThat(recorded.purchase().issue()).contains("预算尚未明确");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80");
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void anotherPurchaseKeyCannotAppendOrOverwriteRecordedPurchase() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
var another=new Purchase(1L,"purchase-2","Sample","A",10,new BigDecimal("90"),"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(3),1,"additional purchase");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",another))).hasMessageContaining("不能再次购买");
assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80");
verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any());
}
@Test void cancellationCannotErasePurchasedFacts() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消");
assertThat(service.read("9","PO-1").purchase().recorded()).isTrue();
}
@Test void beforePurchaseCancellationIsIdempotentAndPreventsNewPurchases() {
var request=new Command(0L,"cancel","customer cancellation");
tx.execute(s->service.cancel("9","PO-1","7",request));tx.execute(s->service.cancel("9","PO-1","7",request));
assertThat(service.read("9","PO-1").status()).isEqualTo("CANCELLED");
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))).isInstanceOf(ResponseStatusException.class);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void warehouseSelectionUsesAuthorityAndCannotChangeAfterFirstReceipt() {
when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE"));
tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(0L,"warehouse",91,"plan")));
assertThat(orders.findNo("9","PO-1").getPurchaseWarehouseName()).isEqualTo("receiving");
jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91 WHERE order_id=11");
assertThatThrownBy(()->tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(1L,"warehouse-2",92,"change")))).hasMessageContaining("首次实收");
verify(warehouses,never()).required(92);
}
@Test void otherCustomerCannotRecordOrReadThisOrder() {
assertThatThrownBy(()->tx.execute(s->service.recordPurchase("10","PO-1","7",purchase("80","Sample")))).hasMessageContaining("当前客户");
assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户");
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void reconfirmingRecordedPurchaseDoesNotRegressPhysicalProgress() {
tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")));
jdbc.update("UPDATE ll_overseas_procurement_order SET status='INTERNATIONAL_TRANSIT' WHERE id=11");
var result=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-again","checked")));
assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT");
verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any());
}
private Purchase purchase(String amount,String name) {
return new Purchase(0L,"purchase-1",name,"A",10,new BigDecimal(amount),"CNY",LocalDateTime.now().minusDays(1).withNano(0),
LocalDateTime.now().plusDays(3).withNano(0),1,"supplier purchase");
}
}
@@ -1,106 +1,65 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 仅创建六张正式采购表,真实 Mapper 验证详情、审核、文件和客户费用不会访问任何旧表。 */
class ProcurementQueryServiceTest {
final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest();
ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base;
ObjectMapper json=new ObjectMapper().findAndRegisterModules();
@BeforeEach void setup()throws Exception {
fixture.setup();var jdbc=fixture.jdbc;
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
base=mock(OverseasProcurementService.class);
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class));
ProcurementWorkflowPersistenceTest fixture;
ProcurementQueryService query;
OverseasProcurementService orders;
OverseasManualFeeService fees;
ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup() throws Exception {
fixture=new ProcurementWorkflowPersistenceTest();fixture.setup();
orders=new OverseasProcurementService();
ReflectionTestUtils.setField(orders,"orders",fixture.orders);
ReflectionTestUtils.setField(orders,"histories",fixture.histories);
ReflectionTestUtils.setField(orders,"procurementFiles",fixture.files);
ReflectionTestUtils.setField(orders,"objectMapper",new ObjectMapper().findAndRegisterModules());
warehouses=mock(ProcurementWarehouseDirectory.class);
ReflectionTestUtils.setField(orders,"warehouseDirectory",warehouses);
fees=mock(OverseasManualFeeService.class);
when(fees.procurementFees("9",11L)).thenReturn(List.of());
query=new ProcurementQueryService(fixture.repository,orders,fees);
}
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception {
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11");
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,confirmed_at,created_by) VALUES(301,11,'9',1,'CONFIRMED',CURRENT_TIMESTAMP,'7')");
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)");
fixture.jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(401,11,'9','PS-1','OVERSEAS',301,CURRENT_TIMESTAMP,'{}','7')");
fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')");
fixture.jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,client_summary,created_by) VALUES(501,11,'9','PN-1','PURCHASE',0,1001,'private evidence','Customer-approved summary','internal-operator')");
fixture.jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(601,11,'9',100,'CNY',150,'CNY',1001,'private budget discussion','reviewer')");
var fee=new OverseasProcurementFee();fee.setId(1L);fee.setFeeNo("PF-1");fee.setPurpose("SAMPLE_PAYMENT");fee.setFeeItemName("样品货款");fee.setFeeVersion(1);fee.setActive(true);fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");fee.setWalletErrorMessage("private database error");fee.setVoucherFileName("private.pdf");fee.setVoucherFileId(1001L);fee.setWalletLedgerId("private-ledger");
when(fixture.finance.all(11)).thenReturn(List.of(fee));
var result=query.customer("9","PO-1");assertThat(result.budget().totalAmount()).isEqualByComparingTo("150");assertThat(result.purchaseNeedsAttention()).isTrue();
assertThat(result.boxes()).singleElement().satisfies(b->assertThat(b.cartonNo()).isEqualTo("BOX-A"));
assertThat(result.shipments()).singleElement().satisfies(s->assertThat(s.trackingNo()).isEqualTo("TRACK-1"));
var output=json.writeValueAsString(result);assertThat(output).contains("BOX-A","TRACK-1","PF-1","Customer-approved summary")
.doesNotContain("private","voucherFileId","walletLedgerId","createdBy","internal-operator","reason","purchaseFacts");
verifyNoInteractions(base);
@Test void currentDetailsReadOnlySixTablesAndReturnNoLegacyActionsOrPhysicalFacts() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=15,purchase_warehouse_name='洛杉矶仓',remark='internal note' WHERE id=11");
var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy"));
assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150");
assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty();
assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty();
assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note");
assertThat(orders.detail("9","CG-1").fees()).isEmpty();
}
@Test void platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() {
var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer");
var result=query.platform("9","PO-1",permissions);
assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1);
assertThat(result.controls().remaining()).hasSize(1);
assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue());
assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");});
assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限"));
@Test void reviewAndRemarkUpdatesNoLongerRequireLegacyFeeOrDirectionTables() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='PENDING_REVIEW' WHERE id=11");
when(warehouses.required(91)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(91L,"LAX","洛杉矶仓","US","ACTIVE"));
var result=fixture.tx.execute(s->orders.review("9","CG-1","7",new OverseasProcurementRequests.Review(0L,"APPROVE","review",null,91L)));
assertThat(result.summary().status()).isEqualTo("RECEIVING");assertThat(result.histories()).hasSize(1);
assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓");
}
@Test void anotherCustomerCannotLoadEvenPublicProjection() {
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base);
@Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception {
assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户");
verifyNoInteractions(fees);
var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason");
when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE")));
var result=query.customer("9","CG-1");
assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();});
assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark");
}
/** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */
@Test void customerReadsOnlyOwnedManualProcurementFeeResults() throws Exception {
var manual = mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class);
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", manual);
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setSourceFeeId(901L); fee.setSourceVersion(1); fee.setCustomerId("9"); fee.setBusinessId(11L);
fee.setBusinessType("OVERSEAS_PROCUREMENT"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE");
fee.setFeeItemCode("SERVICE"); fee.setFeeItemName("采购服务费"); fee.setReviewStatus("PENDING");
fee.setWalletStatus("FREEZE_PENDING"); fee.setWalletErrorMessage("private funding diagnosis");
fee.setAmount(BigDecimal.TEN); fee.setCurrency("CNY"); fee.setReviewReason("private review reason");
var other = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
other.setBusinessType("OVERSEAS_PROCUREMENT"); other.setDirection("RECEIVABLE");
other.setCustomerId("10"); other.setBusinessId(11L);
when(manual.procurementFees("9", 11L)).thenReturn(List.of(fee, other));
var result = query.customer("9", "PO-1");
assertThat(result.fees()).singleElement().satisfies(row -> {
assertThat(row.feeNo()).isEqualTo("901");
assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");
assertThat(row.sourceLinked()).isFalse();
});
assertThat(json.writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason");
private OverseasFeeSource fee(String customer,long order,String direction) {
var fee=new OverseasFeeSource();fee.setSourceFeeId(901L);fee.setSourceVersion(1);fee.setCustomerId(customer);fee.setBusinessId(order);
fee.setBusinessType("OVERSEAS_PROCUREMENT");fee.setDirection(direction);fee.setSourceStatus("ACTIVE");fee.setReviewStatus("PENDING");
fee.setFeeItemCode("SERVICE");fee.setFeeItemName("采购服务费");fee.setWalletStatus("FREEZE_PENDING");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");return fee;
}
@Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() {
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')");
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)");
assertThat(query.customer("9","PO-1").boxes()).isEmpty();
}
/** 新流程不再提示旧购买、装箱和两段核费动作。 */
@Test void simplifiedWorkflowDoesNotExposeLegacySampleActions() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='RECEIVING' WHERE id=11");
assertThat(query.platform("9", "PO-1", Set.of("platform:overseas-business:purchase:buy")).actions()).isEmpty();
}
@Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT);
var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas"));
assertThat(result.customerView().order().procurementDirection()).isEqualTo(ProcurementDirection.EXPORT);
assertThat(result.customerView().progress()).extracting(ProcurementQueryService.Progress::title).contains("国内收货完成","海外收货完成","海外费用核定");
assertThat(result.actions()).filteredOn(a -> a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a -> assertThat(a.label()).isEqualTo("登记国内实收"));
}
}
@@ -1,204 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*;
import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ProcurementTransportPersistenceTest {
JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;
ProcurementWarehouseDirectory warehouses;
ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate;
final Address warehouse=new Address("Overseas","1234567890","US operating address");
final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address");
final Address customer=new Address("Customer","13900000000","Hangzhou customer address");
final LocalDateTime start=LocalDateTime.now().minusHours(2);
@BeforeEach void setup()throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
// H2 CAST(varchar AS JSON) creates a JSON string; FORMAT JSON matches MySQL's object parsing.
jdbc=new JdbcTemplate(ds){@Override public int update(String sql,Object...args){return super.update(sql.replace("CAST(? AS JSON)","? FORMAT JSON"),args);}};
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command",
"ll_procurement_receipt","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item",
"ll_procurement_label_set","ll_procurement_label_item","ll_procurement_estimate_review","ll_procurement_repack")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class);
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample");
order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");
order.setContactName(customer.name());order.setContactPhone(customer.phone());order.setReceivingAddress(customer.address());order.setStatus("WAIT_LABELS");order.setReviewedAt(start.minusDays(1));
order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order);
jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at,actual_warehouse_id,overseas_received_at,packing_version) VALUES(11,'9',?,10,?,91,?,1)",start.minusDays(1),start.minusDays(1),start);
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(101,11,'9','PR-1','OVERSEAS',91,10,0,0,?,'7')",start);
jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'CONFIRMED','7')");
jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)");
jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)");
jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')");
warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse);
finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED");
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO");
when(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE")).thenReturn(estimate);
var json=new ObjectMapper().findAndRegisterModules();
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() {
prepare();var request=shipRequest(Leg.OVERSEAS,"ship");
var result=tx.execute(s->service.ship("9","PO-1","7",request));
assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT");
assertThat(result.shipments()).hasSize(1);assertThat(result.shipments().get(0).id()).isPositive();
assertThat(tx.execute(s->service.ship("9","PO-1","7",request)).shipments()).hasSize(1);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"another")).hasMessageContaining("本段已实际交寄");
assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L);
String address=jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment",String.class);
assertThat(address).contains("US operating address","Shanghai receiving address");
assertThat(jdbc.queryForList("SELECT label_snapshot FROM ll_procurement_shipment_item",String.class)).allSatisfy(v->assertThat(v).contains("receiptId","fileId","cartonNo"));
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void crossOrderFilesWrongPagesMissingCartonsAndUnappliedCartonsCannotShip() {
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,999,1,"carrier","T1"),label(202)))).hasMessageContaining("凭证不属于");
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(label(201)))).hasMessageContaining("全部可寄");
jdbc.update("UPDATE ll_overseas_procurement_file SET content_type='image/png' WHERE file_id=1001");
assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,1001,2,"carrier","T1"),label(202)))).hasMessageContaining("页码");
save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"before-apply")).hasMessageContaining("贴单");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();
}
@Test void replacementLabelNeedsNewReviewAndNewAppliedFactsWithoutAnotherHold() {
prepare();save(Leg.OVERSEAS,List.of(label(201),label(202)));
assertThatThrownBy(()->ship(Leg.OVERSEAS,"stale")).hasMessageContaining("尚未核对有效预估");
review(BigDecimal.ZERO);
assertThatThrownBy(()->ship(Leg.OVERSEAS,"not-applied")).hasMessageContaining("贴单");
apply();ship(Leg.OVERSEAS,"ship");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_label_set",Integer.class)).isEqualTo(2);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
}
@Test void unresolvedManagementBlockerStopsBothActionEligibilityAndActualHandoff() {
prepare();
jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,created_by) VALUES(701,11,'9','PN-1','PURCHASE',1,1002,'customer handling unresolved','7')");
assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).contains("阻断异常");
assertThatThrownBy(()->ship(Leg.OVERSEAS,"blocked")).hasMessageContaining("阻断异常");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();
jdbc.update("UPDATE ll_procurement_control_note SET resolved_at=CURRENT_TIMESTAMP,resolved_by='7',resolution_reason='resolved',resolution_voucher_id=1002 WHERE id=701");
assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).isNull();
assertThat(ship(Leg.OVERSEAS,"after-resolution").shipments()).hasSize(1);
}
@Test void changedPriceReleasesFirstAndPendingMoneyStillBlocksShipping() {
prepare();estimate.setAmount(new BigDecimal("100.00"));estimate.setWalletStatus("FROZEN");
review(new BigDecimal("120.00"));verify(finance).releaseEstimate("9","PO-1",801L);
verify(finance,never()).confirm(anyString(),anyString(),anyString(),any());
estimate.setWalletStatus("RELEASE_PENDING");
assertThatThrownBy(()->review(new BigDecimal("120.00"))).hasMessageContaining("原预估资金未完成");
assertThatThrownBy(()->ship(Leg.OVERSEAS,"pending")).hasMessageContaining("预估国际运费");
}
@Test void differentRequestsFromTwoOperatorsCannotCreateTwoShipments()throws Exception {
prepare();long version=version();int labels=labelVersion(Leg.OVERSEAS);var executor=Executors.newFixedThreadPool(2);var startGate=new CountDownLatch(1);
try {
var futures=new ArrayList<Future<Boolean>>();
for(String key:List.of("one","two"))futures.add(executor.submit(()->{startGate.await();try{tx.execute(s->service.ship("9","PO-1",key,new Ship(version,key,Leg.OVERSEAS,labels,List.of(201L,202L),start.plusHours(1),"actual")));return true;}catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}}));
startGate.countDown();int count=0;for(var future:futures)if(future.get(10,TimeUnit.SECONDS))count++;assertThat(count).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(1);
}finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();}
}
@Test void domesticRepackInvalidatesOldLabelsWithoutChangingOriginalShipmentOrQuantity() {
domesticReceipts();save(Leg.DOMESTIC,List.of(label(201),label(202)));
tx.execute(s->service.repack("9","PO-1","7",new Repack(version(),"repack",201,new BigDecimal("25.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),1002,"damaged box")));
var current=service.read("9","PO-1");
assertThat(current.overseasCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{
assertThat(c.lengthCm()).isEqualByComparingTo("20");assertThat(c.quantity()).isEqualTo(6);assertThat(c.packagingRecordId()).isNull();
});
assertThat(current.domesticCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{
assertThat(c.lengthCm()).isEqualByComparingTo("25");assertThat(c.grossWeightKg()).isEqualByComparingTo("5.125");
assertThat(c.quantity()).isEqualTo(5);assertThat(c.packagingRecordId()).isNotNull();assertThat(c.sources()).containsExactly(new com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source(102,5));
});
assertThatThrownBy(()->ship(Leg.DOMESTIC,"stale-label")).hasMessageContaining("旧面单");
assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull();
save(Leg.DOMESTIC,List.of(label(201),label(202)));jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
var result=ship(Leg.DOMESTIC,"domestic");assertThat(result.status()).isEqualTo("SHIPPED_TO_CUSTOMER");
assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='DOMESTIC'",Long.class)).isEqualTo(9L);
assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='OVERSEAS'",Long.class)).isEqualTo(10L);
verify(finance).requireShipmentReady(11);
}
@Test void domesticHandoffRequiresConfirmedAndSettledChargesAndCustomerChangeEvidence() {
domesticReceipts();
var badAddress=new Address("Changed","13600000000","Other street");
assertThatThrownBy(()->tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"changed-address",Leg.DOMESTIC,1,badAddress,null,List.of(label(201),label(202)),"change")))).hasMessageContaining("确认凭证");
save(Leg.DOMESTIC,List.of(label(201),label(202)));
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=11");
assertThatThrownBy(()->ship(Leg.DOMESTIC,"unknown-charge")).hasMessageContaining("整单费用尚未确认");
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11");
doThrow(ProcurementLifecycleService.conflict("未决资金请求")).when(finance).requireShipmentReady(11);
assertThatThrownBy(()->ship(Leg.DOMESTIC,"funds-pending")).hasMessageContaining("未决资金");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment WHERE leg='DOMESTIC'",Integer.class)).isZero();
}
private void domesticReceipts(){prepare();ship(Leg.OVERSEAS,"international");jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=?,charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(70),start.plusMinutes(80));
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,5,1,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));}
private void prepare(){save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);apply();}
private long version(){return orders.findNo("9","PO-1").getVersion();}
private int labelVersion(Leg leg){return jdbc.queryForObject("SELECT MAX(version) FROM ll_procurement_label_set WHERE order_id=11 AND leg=?",Integer.class,leg.name());}
private Label label(long carton){return new Label(carton,1001,1,"carrier","TRACK-"+carton);}
private ProcurementTransportService.Snapshot save(Leg leg,List<Label> labels){return tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"labels-"+version(),leg,1,leg==Leg.OVERSEAS?domestic:customer,null,labels,"checked labels")));}
private void review(BigDecimal amount){tx.execute(s->service.reviewEstimate("9","PO-1","7",new Estimate(version(),"estimate-"+version(),labelVersion(Leg.OVERSEAS),amount,LocalDate.now(),"quoted")));}
private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));}
private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");}
private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));}
@Test void exportUsesDomesticSenderAndOverseasDestinationThenEndsAfterLocalHandoff() {
var sender = new Address("Domestic warehouse","13800000000","Shenzhen domestic origin");
var receiver = new Address("Overseas warehouse","+1 1234567890","Los Angeles overseas receiving");
var finalReceiver = new Address("Overseas customer","+1 9876543210","San Diego customer destination");
jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=?,contact_name=?,contact_phone=?,receiving_address=? WHERE id=11",
ProcurementDirection.EXPORT,finalReceiver.name(),finalReceiver.phone(),finalReceiver.address());
when(warehouses.sender(91,ProcurementDirection.EXPORT)).thenReturn(sender);
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-labels",Leg.OVERSEAS,1,receiver,null,List.of(label(201),label(202)),"export manifest")));
review(BigDecimal.ZERO); apply();
assertThat(ship(Leg.OVERSEAS,"export-international").status()).isEqualTo("INTERNATIONAL_TRANSIT");
String snapshot = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='OVERSEAS'",String.class);
assertThat(snapshot).contains(sender.address(),receiver.address()).doesNotContain(warehouse.address());
jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,6,0,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=? WHERE order_id=11",start.plusMinutes(70));
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-local-labels",Leg.DOMESTIC,1,finalReceiver,null,List.of(label(201),label(202)),"overseas delivery")));
assertThatThrownBy(() -> ship(Leg.DOMESTIC,"before-charges")).hasMessageContaining("费用尚未确认");
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(80));
var request = shipRequest(Leg.DOMESTIC,"export-local");
assertThat(tx.execute(s -> service.ship("9","PO-1","7",request)).status()).isEqualTo("SHIPPED_TO_CUSTOMER");
tx.execute(s -> service.ship("9","PO-1","7",request));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(2);
String local = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='DOMESTIC'",String.class);
assertThat(local).contains(receiver.address(),finalReceiver.address());
verify(warehouses,never()).sender(91);
verify(finance).requireShipmentReady(11);
}
}
@@ -31,20 +31,23 @@ class ProcurementWorkflowPersistenceTest {
ProcurementWorkflowService service;
OverseasProcurementOrderMapper orders;
OverseasProcurementFileMapper files;
OverseasProcurementStatusHistoryMapper histories;
ProcurementWorkflowRepository repository;
OverseasFeeSourceService fees;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) {
for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_overseas_procurement_status_history","ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);
assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(ProcurementWorkflowMapper.class);
var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementStatusHistoryMapper.class);config.addMapper(ProcurementWorkflowMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());orders=session.getMapper(OverseasProcurementOrderMapper.class);files=session.getMapper(OverseasProcurementFileMapper.class);
var repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
histories=session.getMapper(OverseasProcurementStatusHistoryMapper.class);
repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet());
fees=mock(OverseasFeeSourceService.class);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));