费用提交
This commit is contained in:
+4
-2
@@ -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"));
|
||||
|
||||
+50
@@ -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:");
|
||||
}
|
||||
}
|
||||
+6
-9
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
-145
@@ -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");}
|
||||
}
|
||||
-6
@@ -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
|
||||
|
||||
-260
@@ -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)));}
|
||||
}
|
||||
-130
@@ -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)));}
|
||||
}
|
||||
-31
@@ -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();
|
||||
}
|
||||
}
|
||||
-287
@@ -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)); }
|
||||
}
|
||||
-238
@@ -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")));}
|
||||
}
|
||||
-184
@@ -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");
|
||||
}
|
||||
}
|
||||
+50
-91
@@ -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("登记国内实收"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-204
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+6
-3
@@ -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")));
|
||||
|
||||
Reference in New Issue
Block a user