费用提交
This commit is contained in:
+37
@@ -8,6 +8,43 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OverseasProcurementOpenApiContractTest {
|
||||
/** 简化采购契约须覆盖分批作业、客户确认、库存与受控文件读取。 */
|
||||
@Test
|
||||
void simplifiedWorkflowDocumentsActualBatchContractAndCustomerIsolation() throws Exception {
|
||||
String yaml;
|
||||
try (var input = getClass().getResourceAsStream("/openapi/procurement-workflow-v1.yaml")) {
|
||||
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}";
|
||||
for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:",
|
||||
"/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) {
|
||||
assertTrue(yaml.contains(platform + suffix), suffix);
|
||||
}
|
||||
String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}";
|
||||
assertTrue(yaml.contains(merchant + "/workflow:"));
|
||||
assertTrue(yaml.contains(merchant + "/confirm:"));
|
||||
assertTrue(yaml.contains(merchant + "/files/{fileId}/content:"));
|
||||
for (String permission : new String[]{"platform:overseas-business:purchase:list",
|
||||
"platform:overseas-business:purchase:receive-overseas",
|
||||
"platform:overseas-business:purchase:ship-customer",
|
||||
"merchant:cross-border:procurement:list", "merchant:cross-border:procurement:create"}) {
|
||||
assertTrue(yaml.contains(permission), permission);
|
||||
}
|
||||
assertTrue(yaml.contains("enum: [PENDING_REVIEW, RECEIVING, WAIT_CUSTOMER_CONFIRM, CONFIRMED, IN_TRANSIT, DELIVERING, COMPLETED]"));
|
||||
assertTrue(yaml.contains("required: [version, idempotencyKey]"));
|
||||
assertTrue(yaml.contains("required: [quantity, trackingNo]"));
|
||||
assertTrue(yaml.contains("required: [quantity, fileIds]"));
|
||||
assertTrue(yaml.contains("maxItems: 20"));
|
||||
assertTrue(yaml.contains("uniqueItems: true"));
|
||||
assertTrue(yaml.contains("receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files"));
|
||||
assertTrue(yaml.contains("内部文件和其他客户文件均不可下载"));
|
||||
assertTrue(yaml.contains("format: binary"));
|
||||
assertTrue(yaml.contains("no-store"));
|
||||
String merchantPaths = yaml.substring(yaml.indexOf(merchant + "/workflow:"), yaml.indexOf("components:"));
|
||||
assertFalse(merchantPaths.contains("#/components/parameters/CustomerId"));
|
||||
assertFalse(merchantPaths.contains("purchase:review"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception {
|
||||
String yaml;
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.lastleg.procurement.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
|
||||
import com.baiyue.lastleg.procurement.service.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 正式方法权限与客户隔离均在控制器边界验证。 */
|
||||
class ProcurementWorkflowAuthorizationTest {
|
||||
AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class);
|
||||
ProcurementWorkflowService service=context.getBean(ProcurementWorkflowService.class);
|
||||
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
|
||||
static class Config {
|
||||
@Bean ProcurementWorkflowService service() { return mock(ProcurementWorkflowService.class); }
|
||||
@Bean ProcurementWorkflowController platform(ProcurementWorkflowService service) { return new ProcurementWorkflowController(service); }
|
||||
@Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock() {
|
||||
return f->f.registerSingleton("orders",mock(OverseasProcurementService.class));
|
||||
}
|
||||
@Bean MerchantProcurementWorkflowController merchant(ProcurementWorkflowService service,OverseasProcurementService orders) { return new MerchantProcurementWorkflowController(service,orders); }
|
||||
}
|
||||
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
|
||||
@Test void readerCannotReceiveOrCreateOutbounds() {
|
||||
var controller=context.getBean(ProcurementWorkflowController.class);
|
||||
var user=login(0,"PLATFORM","platform:overseas-business:purchase:list");
|
||||
controller.read(user,"9","CG-1");verify(service).read("9","CG-1");
|
||||
assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
@Test void receiverCanSubmitConfirmationButCannotShip() {
|
||||
var controller=context.getBean(ProcurementWorkflowController.class);
|
||||
var user=login(0,"PLATFORM","platform:overseas-business:purchase:receive-overseas");
|
||||
var command=new Command(1,"ask");controller.requestConfirmation(user,"9","CG-1",command);
|
||||
verify(service).requestConfirmation("9","CG-1","7",command);
|
||||
assertThatThrownBy(()->controller.dispatch(user,"9","CG-1",1,command)).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
@Test void customerCannotUsePlatformEvenWithInjectedAuthorityAndCannotChooseCustomer() {
|
||||
var platform=context.getBean(ProcurementWorkflowController.class);
|
||||
var user=login(9,"MERCHANT","platform:overseas-business:purchase:ship-customer");
|
||||
assertThatThrownBy(()->platform.outbound(user,"10","CG-1",new Outbound(0,"o",1,"T"))).isInstanceOf(ResponseStatusException.class);
|
||||
var merchant=context.getBean(MerchantProcurementWorkflowController.class);
|
||||
var customer=login(9,"MERCHANT","merchant:cross-border:procurement:create");
|
||||
var command=new Command(1,"c");merchant.confirm(customer,"CG-1",command);verify(service).confirm("9","CG-1","7",command);
|
||||
}
|
||||
@Test void customerListPermissionCannotConfirm() {
|
||||
var merchant=context.getBean(MerchantProcurementWorkflowController.class);
|
||||
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
|
||||
merchant.read(user,"CG-1");verify(service).read("9","CG-1");
|
||||
assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
private AuthenticatedUser login(long customer,String app,String permission) {
|
||||
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -138,14 +138,14 @@ class OverseasProcurementServiceTest {
|
||||
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L);
|
||||
|
||||
var approved = service.review("100", "HC1", "operator-7", request);
|
||||
assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status());
|
||||
assertEquals("RECEIVING", approved.summary().status());
|
||||
assertEquals(1L, approved.summary().version());
|
||||
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
|
||||
verify(histories).insert(org.mockito.ArgumentMatchers
|
||||
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
|
||||
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
|
||||
&& "approve-1".equals(value.getIdempotencyKey())
|
||||
&& OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus())));
|
||||
&& "RECEIVING".equals(value.getToStatus())));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L)));
|
||||
@@ -153,7 +153,7 @@ class OverseasProcurementServiceTest {
|
||||
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L)));
|
||||
assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus());
|
||||
assertEquals("RECEIVING", order.getStatus());
|
||||
}
|
||||
|
||||
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
|
||||
|
||||
+4
-7
@@ -115,13 +115,10 @@ class ProcurementPurchasePersistenceTest {
|
||||
|
||||
@Test void unreviewedPurchaseFactCannotConfirmOrCharge() {
|
||||
jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' 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("尚未审核");
|
||||
var again=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-unreviewed","actual fact")));
|
||||
assertThat(again.purchase().confirmed()).isFalse();
|
||||
assertThat(again.status()).isEqualTo("PENDING_REVIEW");
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -30,6 +30,7 @@ class ProcurementQueryServiceTest {
|
||||
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));
|
||||
}
|
||||
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception {
|
||||
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11");
|
||||
@@ -60,12 +61,40 @@ class ProcurementQueryServiceTest {
|
||||
@Test void anotherCustomerCannotLoadEvenPublicProjection() {
|
||||
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base);
|
||||
}
|
||||
/** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */
|
||||
@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");
|
||||
}
|
||||
@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"));
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
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.ProcurementWorkflowDtos.*;
|
||||
import com.baiyue.lastleg.procurement.entity.*;
|
||||
import com.baiyue.lastleg.procurement.mapper.*;
|
||||
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
|
||||
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实Mapper验证采购库存分批、隔离、事务与幂等。 */
|
||||
class ProcurementWorkflowPersistenceTest {
|
||||
JdbcTemplate jdbc;
|
||||
TransactionTemplate tx;
|
||||
ProcurementWorkflowService service;
|
||||
OverseasProcurementOrderMapper orders;
|
||||
OverseasProcurementFileMapper files;
|
||||
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")) {
|
||||
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 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);
|
||||
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")));
|
||||
service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules(),fees);
|
||||
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order);
|
||||
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
|
||||
}
|
||||
|
||||
@Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() {
|
||||
var first=new Receipt(0,"receive1",4,List.of(101L));
|
||||
var current=tx.execute(s->service.receive("9","CG-1","7",first));
|
||||
assertThat(current.stockQuantity()).isEqualTo(4);
|
||||
current=tx.execute(s->service.receive("9","CG-1","7",new Receipt(1,"receive2",6,List.of(104L))));
|
||||
assertThat(current.receipts()).hasSize(2);assertThat(current.availableQuantity()).isEqualTo(10);
|
||||
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(2,"ask")));
|
||||
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"early",4,"TN")))).isInstanceOf(ResponseStatusException.class);
|
||||
tx.execute(s->service.confirm("9","CG-1","8",new Command(3,"confirm")));
|
||||
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"out1",4,"TN-1")));
|
||||
long batch1=current.outbounds().get(0).id();
|
||||
assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.reservedQuantity()).isEqualTo(4);assertThat(current.availableQuantity()).isEqualTo(6);
|
||||
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(5,"out2",6,"TN-2")));
|
||||
long batch2=current.outbounds().get(1).id();
|
||||
tx.execute(s->service.dispatch("9","CG-1",batch1,"7",new Command(6,"dispatch1")));
|
||||
var completion=new Completion(7,"complete1",List.of(102L));
|
||||
current=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion));
|
||||
assertThat(current.stockQuantity()).isEqualTo(6);assertThat(current.reservedQuantity()).isEqualTo(6);assertThat(current.status()).isEqualTo("IN_TRANSIT");
|
||||
var replay=tx.execute(s->service.complete("9","CG-1",batch1,"7",completion));
|
||||
assertThat(replay.version()).isEqualTo(8);assertThat(replay.stockQuantity()).isEqualTo(6);
|
||||
tx.execute(s->service.dispatch("9","CG-1",batch2,"7",new Command(8,"dispatch2")));
|
||||
current=tx.execute(s->service.complete("9","CG-1",batch2,"7",new Completion(9,"complete2",List.of(101L))));
|
||||
assertThat(current.status()).isEqualTo("COMPLETED");assertThat(current.stockQuantity()).isZero();assertThat(current.availableQuantity()).isZero();
|
||||
assertThat(orders.findNo("9","CG-1").getInboundQuantity()).isZero();
|
||||
assertThat(current.files()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test void partialPurchaseCanFinishButRemainingStockRequiresMoreOutbounds() {
|
||||
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",6,List.of(101L))));
|
||||
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
|
||||
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
|
||||
var current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"o",2,"TRACK")));
|
||||
long batch=current.outbounds().get(0).id();
|
||||
tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d")));
|
||||
current=tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"c",List.of(102L))));
|
||||
assertThat(current.status()).isEqualTo("CONFIRMED");assertThat(current.stockQuantity()).isEqualTo(4);
|
||||
current=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"o2",4,"TRACK-2")));
|
||||
assertThat(current.availableQuantity()).isZero();assertThat(current.outbounds()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test void validatesQuantityPhotosCustomerFilesVersionsAndIdempotency() {
|
||||
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"pdf",1,List.of(102L))))).hasMessageContaining("图片");
|
||||
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"foreign",1,List.of(103L))))).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"over",11,List.of(101L))))).hasMessageContaining("超过");
|
||||
assertThatThrownBy(()->service.read("10","CG-1")).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"empty")))).hasMessageContaining("收货");
|
||||
var request=new Receipt(0,"r",6,List.of(101L));tx.execute(s->service.receive("9","CG-1","7",request));
|
||||
assertThat(tx.execute(s->service.receive("9","CG-1","7",request)).version()).isEqualTo(1);
|
||||
assertThatThrownBy(()->tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",5,List.of(101L))))).hasMessageContaining("幂等键");
|
||||
assertThatThrownBy(()->tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(0,"stale")))).hasMessageContaining("已更新");
|
||||
service.requireVisibleFile("9","CG-1",101L);
|
||||
assertThatThrownBy(()->service.requireVisibleFile("9","CG-1",102L)).hasMessageContaining("未关联");
|
||||
}
|
||||
|
||||
@Test void concurrentOutboundsCannotOversellAndCompletionRequiresDispatchAndProof() throws Exception {
|
||||
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
|
||||
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
|
||||
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
|
||||
var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var start=new CountDownLatch(1);var succeeded=new AtomicInteger();
|
||||
var tasks=new ArrayList<Future<?>>();
|
||||
for(int i=0;i<2;i++) {int index=i;tasks.add(pool.submit(()->{try {start.await();tx.execute(s->service.outbound("9","CG-1","7",new Outbound(3,"out"+index,7,"TN"+index)));succeeded.incrementAndGet();}catch(ResponseStatusException expected) { }catch(InterruptedException e) {throw new RuntimeException(e);}}));}
|
||||
start.countDown();for(var future:tasks)future.get(10,TimeUnit.SECONDS);
|
||||
assertThat(succeeded.get()).isEqualTo(1);
|
||||
} finally {pool.shutdownNow();}
|
||||
var current=service.read("9","CG-1");assertThat(current.availableQuantity()).isEqualTo(3);
|
||||
long batch=current.outbounds().get(0).id();
|
||||
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",new Outbound(4,"over",4,"T")))).hasMessageContaining("超过");
|
||||
assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(4,"early",List.of(102L))))).hasMessageContaining("派送中");
|
||||
tx.execute(s->service.dispatch("9","CG-1",batch,"7",new Command(4,"d")));
|
||||
assertThatThrownBy(()->tx.execute(s->service.complete("9","CG-1",batch,"7",new Completion(5,"empty",List.of())))).hasMessageContaining("凭证");
|
||||
assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test void outboundRequiresCurrentReviewedReceivablesAndRejectedAttemptsLeaveNoInventoryChanges() {
|
||||
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
|
||||
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
|
||||
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
|
||||
var request=new Outbound(3,"out",3,"TRACK");
|
||||
for(var rows:List.of(List.<OverseasFeeSource>of(),List.of(fee("VOIDED","APPROVED")))) {
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(rows);
|
||||
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
|
||||
}
|
||||
var payable=fee("ACTIVE","APPROVED");payable.setDirection("PAYABLE");
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(payable));
|
||||
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
|
||||
for(String review:List.of("PENDING","REJECTED")) {
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("ACTIVE",review)));
|
||||
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("尚未全部审核通过");
|
||||
}
|
||||
assertThat(service.read("9","CG-1").version()).isEqualTo(3);
|
||||
assertThat(service.read("9","CG-1").reservedQuantity()).isZero();
|
||||
assertThat(service.read("9","CG-1").outbounds()).isEmpty();
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("VOIDED","REJECTED")));
|
||||
var result=tx.execute(s->service.outbound("9","CG-1","7",request));
|
||||
assertThat(result.reservedQuantity()).isEqualTo(3);
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of());
|
||||
assertThat(tx.execute(s->service.outbound("9","CG-1","7",request)).version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
private OverseasFeeSource fee(String status,String review) {
|
||||
var fee=new OverseasFeeSource();fee.setDirection("RECEIVABLE");fee.setSourceStatus(status);fee.setReviewStatus(review);return fee;
|
||||
}
|
||||
|
||||
private void file(long id,long orderId,String customer,String type) {
|
||||
var file=new OverseasProcurementFile();file.setId(id);file.setOrderId(orderId);file.setCustomerId(customer);file.setFileId(id);file.setCategory("RECEIPT");file.setFileName("proof-"+id);file.setContentType(type);file.setCreatedBy("7");files.insert(file);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user