This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
@@ -44,6 +44,20 @@ class InternalInboundOrderReferenceControllerTest {
@Resource
private InternalInboundOrderController controller;
/** 新增状态查询和摘要入口都必须校验内部服务身份。 */
@Test
void statusEndpointsRequireInternalTokenBeforeDelegation() {
var request = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, List.of(77L), null, null, 1, 20, null);
assertThatThrownBy(() -> controller.unifiedStatusRows(null, request)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.unifiedStatusSummaries("wrong", request)).isInstanceOf(BizException.class);
org.mockito.Mockito.verifyNoInteractions(unifiedQueryService);
when(unifiedQueryService.statusRows(request)).thenReturn(List.of());
when(unifiedQueryService.statusSummaries(request)).thenReturn(List.of());
assertThat(controller.unifiedStatusRows("internal-token", request).data()).isEmpty();
assertThat(controller.unifiedStatusSummaries("internal-token", request).data()).isEmpty();
}
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
@@ -9,6 +9,16 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionSqlContractTest {
/** 后执行的渠道柜次脚本不能恢复旧触发器并阻断持久化完成命令。 */
@Test
void channelMigrationKeepsTheDurableCompletionTransitions() throws Exception {
String sql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-channel-container-migration.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')")
.contains("OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')")
.contains("OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING'")
.doesNotContain("任务状态只允许完成");
}
@Test
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
int expectedTables = 227;
int expectedTables = 228;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -69,6 +69,17 @@ class UnifiedInboundTaxModePaginationTest {
assertPage(null, 1, 4, 6);
assertPage(null, 2, 4, 4);
}
/** 轻量统计读取全量状态,不能把第二页当作全量,也不能越过客户和软删除条件。 */
@Test
void statusProjectionUsesFullFilteredSetAndEmptyCandidatesStayEmpty() {
var query = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, 2, 1, "TAX_REFUND");
assertThat(repository.unifiedStatusEntities(query)).extracting(InboundOrderEntity::getId).containsExactly(6L, 2L);
var empty = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, List.of(), null, null, 2, 1, "TAX_REFUND");
assertThat(repository.unifiedStatusEntities(empty)).isEmpty();
}
private void assertPage(String taxMode, long page, long total, long expectedId) {
var query = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode);
var result = repository.pageUnifiedEntities(query, List.of("LOADED"));
@@ -50,6 +50,18 @@ class ContainerServiceTest {
@MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service;
/** 实际开船可独立打开清关资料,预估节点与缺失货柜不能绕过校验。 */
@Test void clearanceAcceptsActualSailingWithoutInventingOutboundConfirmation() {
when(repository.find(91L)).thenReturn(new ContainerBookingEntity());
assertThatThrownBy(() -> service.requireClearanceAccess(91L)).isInstanceOf(BizException.class);
when(repository.sailed(91L)).thenReturn(true);
service.requireClearanceAccess(91L);
assertThatThrownBy(() -> service.requireClearanceAccess(92L)).isInstanceOf(BizException.class);
when(repository.sailed(91L)).thenReturn(false);
when(repository.departed(91L)).thenReturn(true);
service.requireClearanceAccess(91L);
}
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
@@ -9,6 +9,20 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest {
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
@Test void customerQueueUsesIndependentWeightConfirmation() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"weights",weights);
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
assertThat(response.feeOrderIds()).containsExactly(101L);
verify(weights).pendingOrders(11);
verify(mapper,never()).pendingFees(anyLong());
}
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var unified=mock(UnifiedInboundQueryService.class);
@@ -24,11 +24,77 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UnifiedInboundQueryServiceTest {
/** 直接内部查询同样先按业务事实筛选再分页,不能返回未命中的订单。 */
@Test
void workflowPageFiltersBeforePagination() {
var repository = mock(InboundOrderRepository.class);
var workflow = mock(com.baiyue.firstleg.workflow.WorkflowTabService.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "inbounds", repository);
ReflectionTestUtils.setField(service, "workflowTabs", workflow);
ReflectionTestUtils.setField(service, "operations", mock(UnifiedInboundReadRepository.class));
ReflectionTestUtils.setField(service, "pricing", mock(FbaPricingRepository.class));
var query = new UnifiedInboundQuery(10001L, null, null, "WAIT_WEIGHT_CONFIRM", null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, 2, 1, null);
var first = new InboundOrderEntity(); first.setId(1L); first.setTenantId(10001L); first.setOrderId(77L); first.setProcessStatus("WAIT_SPLIT");
var second = new InboundOrderEntity(); second.setId(2L); second.setTenantId(10001L); second.setOrderId(78L); second.setProcessStatus("WAIT_SPLIT");
var other = new InboundOrderEntity(); other.setId(3L); other.setTenantId(10001L); other.setOrderId(79L); other.setProcessStatus("WAIT_SPLIT");
var sources = List.of(first, second, other);
when(repository.unifiedStatusEntities(query)).thenReturn(sources);
when(workflow.rows(sources)).thenReturn(List.of(
new UnifiedInboundQueryService.StatusRow(10001L,77L,"WAIT_WEIGHT_CONFIRM"),
new UnifiedInboundQueryService.StatusRow(10001L,78L,"WAIT_WEIGHT_CONFIRM"),
new UnifiedInboundQueryService.StatusRow(10001L,79L,"WEIGHT_CONFIRMED")));
when(repository.unifiedMatchedEntities(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq(List.of())))
.thenReturn(sources);
var result = service.page(query);
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).singleElement().satisfies(row -> assertThat(row.inbound().orderId()).isEqualTo(78));
}
/** 分片订单保留各状态命中,同一状态内去重;不触发逐票富化查询。 */
@Test
void statusRowsDeduplicateLatestMatchingInboundWithoutEnrichment() {
var repository = mock(InboundOrderRepository.class);
var operations = mock(UnifiedInboundReadRepository.class);
var pricing = mock(FbaPricingRepository.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service, "inbounds", repository);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "pricing", pricing);
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, 9, 1, null);
var newest = new InboundOrderEntity(); newest.setTenantId(10001L); newest.setOrderId(77L);
newest.setProcessStatus("MEASURE_PENDING");
var older = new InboundOrderEntity(); older.setTenantId(10001L); older.setOrderId(77L);
older.setProcessStatus("LOADED");
when(repository.unifiedStatusEntities(query)).thenReturn(List.of(newest, newest, older));
assertThat(service.statusRows(query)).containsExactly(
new UnifiedInboundQueryService.StatusRow(10001L, 77L, "WAIT_MEASURE"),
new UnifiedInboundQueryService.StatusRow(10001L, 77L, "LOADED"));
verifyNoInteractions(operations, pricing);
}
/** 摘要入口必须提供当前页候选,不能误触发全量明细读取。 */
@Test
void statusSummariesRejectUnboundedCandidateRequest() {
var repository = mock(InboundOrderRepository.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service, "inbounds", repository);
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20, null);
assertThatThrownBy(() -> service.statusSummaries(query)).isInstanceOf(BizException.class);
verifyNoInteractions(repository);
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"})
void illegalTaxModeFailsBeforeRepository(String taxMode) {
var repository = mock(InboundOrderRepository.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service, "inbounds", repository);
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20, taxMode);
@@ -144,6 +210,7 @@ class UnifiedInboundQueryServiceTest {
var operations = mock(UnifiedInboundReadRepository.class);
var pricing = mock(FbaPricingRepository.class);
var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service,"inbounds",inbounds);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"pricing",pricing);
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 跨服务缺项和客户归属异常不能生成看似成功的部分统计。 */
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@Test void rejectsMissingAndForeignDownstreamFacts() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
}
/** 重测同值也要求重新确认,金额没有参与摘要。 */
@Test void repeatedMeasurementRequiresNewConfirmation() {
var original=fact();
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
}
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
var service=mock(ChargeableWeightService.class);
var controller=new MerchantChargeableWeightController(service);
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
var request=new WorkflowFacts.Confirm("a".repeat(64));
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
controller.read(merchant,101);
controller.confirm(merchant,1,"key",request);
verify(service).read(11,101);
verify(service).confirm(11,7,1,"key",request);
}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和独立计费重版本回归。 */
class WorkflowBusinessTest {
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var time=LocalDateTime.of(2026,10,19,10,0);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
var f=fact(1,"100",null,true);
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH").doesNotContain("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(f,"LOADED",created)).contains("OUTBOUND_DOCUMENT").doesNotContain("WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(f,"LOADED",shipped)).contains("DELIVERING").doesNotContain("WAIT_DISPATCH","OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH");
}
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
@Test void independentWeightVersion() {
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
}
}
@@ -0,0 +1,120 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.nio.file.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
class WorkflowFactPersistenceTest {
/** 测试建表不连接业务数据库。 */
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
db.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(30),picked_package_count INT)");
db.execute("CREATE TABLE fl_container_booking(id BIGINT,record_status VARCHAR(30))");
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
db.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)");
db.execute("CREATE TABLE fl_customs_customer_document_inbound(inbound_id BIGINT,tenant_id BIGINT,document_id BIGINT)");
db.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,tenant_id BIGINT,status VARCHAR(30),customs_container_id BIGINT)");
db.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,status VARCHAR(40))");
db.execute("ALTER TABLE fl_inbound_order ADD tax_mode VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
db.update("INSERT INTO fl_container_booking VALUES(5,'ACTIVE')");
db.update("INSERT INTO fl_loading_pick_task VALUES(6,4,'COMPLETED')");
db.update("INSERT INTO fl_loading_pick_item VALUES(6,3,'PICKED',2)");
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
assertThat(containers.hasActualVesselDeparture(5)).isOne();
assertThat(containers.hasActualVesselDeparture(6)).isZero();
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
assertThat(containers.hasActualVesselDeparture(6)).isZero();
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
assertThat(service.read(22,101)).isEmpty();
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
db.update("INSERT INTO fl_customs_container VALUES(8,4,'WAITING_CUSTOMER_DOCUMENTS')");
db.update("INSERT INTO fl_customs_customer_document VALUES(9,22,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,22,9)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
});
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();});
// 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue();
}
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
});
db.update("UPDATE fl_customs_customer_document SET status='ACTIVE' WHERE id=10");
db.update("INSERT INTO fl_customs_container VALUES(18,14,'READY_TO_START')");
db.update("UPDATE fl_customs_customer_document SET customs_container_id=18 WHERE id=10");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_customs_customer_document SET customs_container_id=8 WHERE id=10");
db.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=4");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_loading_plan SET plan_status='COMPLETED' WHERE id=4");
db.update("UPDATE fl_loading_pick_item SET picked_package_count=0");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_loading_pick_item SET picked_package_count=2");
// 不把预计开船当成实际开船;手动或拉取写入的实际节点都读同一事实表。
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL");
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isFalse();
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
// 买单票无需客户上传退税资料,但同样必须有实际装柜证据。
assertThat(mapper.facts(List.of(2L)).get(0).exportCustomsReady()).isFalse();
db.update("INSERT INTO fl_loading_plan_item VALUES(13,2,22,4,0)");
db.update("INSERT INTO fl_loading_pick_item VALUES(6,13,'PICKED',1)");
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
});
var current=service.read(11,101).get(0);
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
assertThat(service.read(11,101)).hasSize(1);
assertThat(service.read(11,103)).hasSize(1);
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
assertThat(service.read(11,101)).isEmpty();
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var child=service.read(11,103).get(0);
assertThat(child.confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
}
}